1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-04 13:48:56 +02:00

fix: Stores as typescript and with interfaces. (#902)

Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com>
This commit is contained in:
Christopher Kolstad 2021-08-12 15:04:37 +02:00 committed by GitHub
parent c5b8b2f920
commit ff7be7696c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
381 changed files with 7458 additions and 5121 deletions

View File

@ -8,3 +8,4 @@ website/translated_docs
website/core website/core
website/pages website/pages
websitev2 websitev2
setupJest.js

View File

@ -5,18 +5,17 @@
}, },
"extends": [ "extends": [
"airbnb-typescript/base", "airbnb-typescript/base",
"prettier" "plugin:prettier/recommended"
], ],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"parserOptions": { "parserOptions": {
"ecmaVersion": 2019, "ecmaVersion": 2019,
"project": "./tsconfig.json" "project": "./tsconfig.json"
}, },
"plugins": ["prettier","@typescript-eslint"], "plugins": ["@typescript-eslint","prettier"],
"root": true, "root": true,
"rules": { "rules": {
"@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/indent": ["error", 4],
"@typescript-eslint/naming-convention": 0, "@typescript-eslint/naming-convention": 0,
"@typescript-eslint/space-before-function-paren": 0, "@typescript-eslint/space-before-function-paren": 0,
"import/prefer-default-export": 0, "import/prefer-default-export": 0,
@ -46,10 +45,15 @@
"files": ["*.ts", "*.tsx"], "files": ["*.ts", "*.tsx"],
"rules": { "rules": {
"@typescript-eslint/explicit-module-boundary-types": ["error"], "@typescript-eslint/explicit-module-boundary-types": ["error"],
"@typescript-eslint/indent": ["error"],
"@typescript-eslint/naming-convention": ["error"], "@typescript-eslint/naming-convention": ["error"],
"@typescript-eslint/space-before-function-paren": ["error"] "@typescript-eslint/space-before-function-paren": ["error"]
} }
},
{
"files": ["src/test/e2e/helpers/test-helper.ts"],
"rules": {
"import/no-extraneous-dependencies": "off"
}
} }
], ],
"ignorePatterns": ["**/docs/api/oas/", "examples/**"] "ignorePatterns": ["**/docs/api/oas/", "examples/**"]

View File

@ -19,6 +19,7 @@
"bugs": { "bugs": {
"url": "https://github.com/unleash/unleash/issues" "url": "https://github.com/unleash/unleash/issues"
}, },
"types": "./dist/lib/types/index.d.js",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
}, },
@ -43,6 +44,10 @@
"clean": "del-cli --force dist" "clean": "del-cli --force dist"
}, },
"jest": { "jest": {
"automock": false,
"setupFiles": [
"./setupJest.js"
],
"transform": { "transform": {
"^.+\\.tsx?$": "ts-jest" "^.+\\.tsx?$": "ts-jest"
}, },
@ -109,37 +114,42 @@
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.11", "@types/express": "^4.17.11",
"@types/express-session": "^1.17.4",
"@types/jest": "^26.0.23", "@types/jest": "^26.0.23",
"@types/js-yaml": "^3.12.7",
"@types/memoizee": "^0.4.6",
"@types/node": "^15.6.0", "@types/node": "^15.6.0",
"@types/node-fetch": "^2.5.10", "@types/node-fetch": "^2.5.10",
"@types/nodemailer": "^6.4.1", "@types/nodemailer": "^6.4.1",
"@types/owasp-password-strength-test": "^1.3.0", "@types/owasp-password-strength-test": "^1.3.0",
"@types/stoppable": "^1.1.1", "@types/stoppable": "^1.1.1",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^4.22.0", "@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0", "@typescript-eslint/parser": "^4.22.0",
"copyfiles": "^2.4.1", "copyfiles": "^2.4.1",
"coveralls": "^3.1.0", "coveralls": "^3.1.0",
"del-cli": "^4.0.1", "del-cli": "^4.0.1",
"eslint": "^6.8.0", "eslint": "^7.32.0",
"eslint-config-airbnb-base": "^14.2.1", "eslint-config-airbnb-base": "^14.2.1",
"eslint-config-airbnb-typescript": "^12.3.1", "eslint-config-airbnb-typescript": "^12.3.1",
"eslint-config-prettier": "^8.1.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.23.4",
"eslint-plugin-prettier": "^3.3.1", "eslint-plugin-prettier": "^3.4.0",
"faker": "^5.5.3", "faker": "^5.5.3",
"fetch-mock": "^9.11.0", "fetch-mock": "^9.11.0",
"husky": "^4.2.3", "husky": "^4.2.3",
"jest": "^27.0.1", "jest": "^27.0.6",
"jest-fetch-mock": "^3.0.3",
"lint-staged": "^11.0.0", "lint-staged": "^11.0.0",
"prettier": "^1.19.1", "prettier": "^2.3.2",
"proxyquire": "^2.1.3", "proxyquire": "^2.1.3",
"source-map-support": "^0.5.19", "source-map-support": "^0.5.19",
"superagent": "^6.1.0", "superagent": "^6.1.0",
"supertest": "^6.1.3", "supertest": "^6.1.3",
"ts-jest": "^27.0.0", "ts-jest": "^27.0.4",
"ts-node": "^10.0.0", "ts-node": "^10.1.0",
"tsc-watch": "^4.4.0", "tsc-watch": "^4.4.0",
"typescript": "^4.2.4" "typescript": "^4.3.5"
}, },
"resolutions": { "resolutions": {
"set-value": "^2.0.1", "set-value": "^2.0.1",

2
setupJest.js Normal file
View File

@ -0,0 +1,2 @@
// adds the 'fetchMock' global variable and rewires 'fetch' global to call 'fetchMock' instead of the real implementation
require('jest-fetch-mock').enableMocks();

View File

@ -1,11 +0,0 @@
# Snapshot report for `dist/lib/addons/slack.test.js`
The actual snapshot is saved in `slack.test.js.snap`.
Generated by [AVA](https://avajs.dev).
## Should call slack webhook
> Snapshot 1
'{"username":"Unleash","icon_emoji":":unleash:","text":"some@user.com created feature toggle <http://some-url.com/#/features/strategies/some-toggle|some-toggle>\\n*Enabled*: no | *Type*: undefined | *Project*: undefined\\n*Activation strategies*: ```- name: default\\n```","channel":"#undefined","attachments":[{"actions":[{"name":"featureToggle","text":"Open in Unleash","type":"button","value":"featureToggle","style":"primary","url":"http://some-url.com/#/features/strategies/some-toggle"}]}]}'

View File

@ -1,17 +0,0 @@
# Snapshot report for `src/lib/addons/datadog.test.js`
The actual snapshot is saved in `datadog.test.js.snap`.
Generated by [AVA](https://avajs.dev).
## Should call datadog webhook
> Snapshot 1
'{"text":"%%% \\n some@user.com created feature toggle [some-toggle](http://some-url.com/features/strategies/some-toggle)\\n**Enabled**: no | **Type**: undefined | **Project**: undefined\\n**Activation strategies**: ```- name: default\\n``` \\n %%% ","title":"Unleash notification update"}'
## Should call datadog webhook for archived toggle
> Snapshot 1
'{"text":"%%% \\n The feature toggle *[some-toggle](http://some-url.com/archive/strategies/some-toggle)* was *archived* by some@user.com. \\n %%% ","title":"Unleash notification update"}'

View File

@ -1,17 +0,0 @@
# Snapshot report for `src/lib/addons/slack.test.js`
The actual snapshot is saved in `slack.test.js.snap`.
Generated by [AVA](https://avajs.dev).
## Should call slack webhook
> Snapshot 1
'{"username":"Unleash","icon_emoji":":unleash:","text":"some@user.com created feature toggle <http://some-url.com/features/strategies/some-toggle|some-toggle>\\n*Enabled*: no | *Type*: undefined | *Project*: undefined\\n*Activation strategies*: ```- name: default\\n```","channel":"#undefined","attachments":[{"actions":[{"name":"featureToggle","text":"Open in Unleash","type":"button","value":"featureToggle","style":"primary","url":"http://some-url.com/features/strategies/some-toggle"}]}]}'
## Should call slack webhook for archived toggle
> Snapshot 1
'{"username":"Unleash","icon_emoji":":unleash:","text":"The feature toggle *<http://some-url.com/archive/strategies/some-toggle|some-toggle>* was *archived* by some@user.com.","channel":"#undefined","attachments":[{"actions":[{"name":"featureToggle","text":"Open in Unleash","type":"button","value":"featureToggle","style":"primary","url":"http://some-url.com/archive/strategies/some-toggle"}]}]}'

View File

@ -1,17 +0,0 @@
# Snapshot report for `src/lib/addons/teams.test.js`
The actual snapshot is saved in `teams.test.js.snap`.
Generated by [AVA](https://avajs.dev).
## Should call teams webhook
> Snapshot 1
'{"themeColor":"0076D7","summary":"Message","sections":[{"activityTitle":"Feature toggle some-toggle | *Type*: undefined | *Project*: undefined <br /> *Activation strategies*: \\n- name: default\\n","activitySubtitle":"Unleash notification update","facts":[{"name":"User","value":"some@user.com"},{"name":"Action","value":"Create"},{"name":"Enabled","value":"*no*"}]}],"potentialAction":[{"@type":"OpenUri","name":"Go to feature","targets":[{"os":"default","uri":"http://some-url.com/features/strategies/some-toggle"}]}]}'
## Should call teams webhook for archived toggle
> Snapshot 1
'{"themeColor":"0076D7","summary":"Message","sections":[{"activityTitle":"The feature toggle *some-toggle* was *archived*","activitySubtitle":"Unleash notification update","facts":[{"name":"User","value":"some@user.com"},{"name":"Action","value":"feature-archived"},{"name":"Enabled","value":"*no*"}]}],"potentialAction":[{"@type":"OpenUri","name":"Go to feature","targets":[{"os":"default","uri":"http://some-url.com/archive/strategies/some-toggle"}]}]}'

View File

@ -1,8 +1,8 @@
const joi = require('joi'); import joi from 'joi';
const { nameType } = require('../routes/admin-api/util'); import { nameType } from '../routes/admin-api/util';
const { tagTypeSchema } = require('../services/tag-type-schema'); import { tagTypeSchema } from '../services/tag-type-schema';
const addonDefinitionSchema = joi.object().keys({ export const addonDefinitionSchema = joi.object().keys({
name: nameType, name: nameType,
displayName: joi.string(), displayName: joi.string(),
documentationUrl: joi.string().uri({ scheme: [/https?/] }), documentationUrl: joi.string().uri({ scheme: [/https?/] }),
@ -21,16 +21,6 @@ const addonDefinitionSchema = joi.object().keys({
sensitive: joi.boolean().default(false), sensitive: joi.boolean().default(false),
}), }),
), ),
events: joi events: joi.array().optional().items(joi.string()),
.array() tagTypes: joi.array().optional().items(tagTypeSchema),
.optional()
.items(joi.string()),
tagTypes: joi
.array()
.optional()
.items(tagTypeSchema),
}); });
module.exports = {
addonDefinitionSchema,
};

View File

@ -1,71 +0,0 @@
const fetchMock = require('fetch-mock').sandbox();
const noLogger = require('../../test/fixtures/no-logger');
jest.mock('node-fetch', () => fetchMock);
const Addon = require('./addon');
beforeEach(() => {
fetchMock.restore();
fetchMock.reset();
});
const definition = {
name: 'test',
displayName: 'test',
description: 'hello',
};
test('Retries if fetch throws', async () => {
const url = 'https://test.some.com';
jest.useFakeTimers('modern');
const addon = new Addon(definition, {
getLogger: noLogger,
});
fetchMock
.once(
{
name: 'rejection',
type: 'POST',
url: `begin:${url}`,
},
() => {
throw new Error('Network error');
},
)
.mock(
{
name: 'acceptance',
type: 'POST',
url: `begin:${url}`,
},
201,
);
await addon.fetchRetry(url);
jest.advanceTimersByTime(1000);
expect(fetchMock.done()).toBe(true);
jest.useRealTimers();
});
test('does not crash even if fetch throws', async () => {
const url = 'https://test.some.com';
jest.useFakeTimers('modern');
const addon = new Addon(definition, {
getLogger: noLogger,
});
fetchMock.mock(
{
name: 'rejection',
type: 'POST',
url: `begin:${url}`,
},
() => {
throw new Error('Network error');
},
);
await addon.fetchRetry(url);
jest.advanceTimersByTime(1000);
expect(fetchMock.done()).toBe(true);
expect(fetchMock.calls()).toHaveLength(2);
jest.useRealTimers();
});

View File

@ -0,0 +1,40 @@
import fetchMock from 'jest-fetch-mock';
import noLogger from '../../test/fixtures/no-logger';
import SlackAddon from './slack';
beforeEach(() => {
fetchMock.resetMocks();
});
test('Retries if fetch throws', async () => {
const url = 'https://test.some.com';
jest.useFakeTimers('modern');
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: url,
});
fetchMock.mockIf('https://test.some.com', 'OK', {
status: 201,
statusText: 'ACCEPTED',
});
await addon.fetchRetry(url);
jest.advanceTimersByTime(1000);
jest.useRealTimers();
});
test('does not crash even if fetch throws', async () => {
const url = 'https://test.some.com';
jest.useFakeTimers('modern');
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: url,
});
fetchMock.mockResponse(() => {
throw new Error('Network error');
});
await addon.fetchRetry(url);
jest.advanceTimersByTime(1000);
expect(fetchMock.mock.calls).toHaveLength(2);
jest.useRealTimers();
});

View File

@ -1,10 +1,20 @@
'use strict'; import fetch, { Response } from 'node-fetch';
import { addonDefinitionSchema } from './addon-schema';
import { IUnleashConfig } from '../types/option';
import { Logger } from '../logger';
import { IAddonDefinition, IEvent } from '../types/model';
const fetch = require('node-fetch'); export default abstract class Addon {
const { addonDefinitionSchema } = require('./addon-schema'); logger: Logger;
class Addon { _name: string;
constructor(definition, { getLogger }) {
_definition: IAddonDefinition;
constructor(
definition: IAddonDefinition,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
) {
this.logger = getLogger(`addon/${definition.name}`); this.logger = getLogger(`addon/${definition.name}`);
const { error } = addonDefinitionSchema.validate(definition); const { error } = addonDefinitionSchema.validate(definition);
if (error) { if (error) {
@ -18,15 +28,20 @@ class Addon {
this._definition = definition; this._definition = definition;
} }
get name() { get name(): string {
return this._name; return this._name;
} }
get definition() { get definition(): IAddonDefinition {
return this._definition; return this._definition;
} }
async fetchRetry(url, options = {}, retries = 1, backoff = 300) { async fetchRetry(
url: string,
options = {},
retries: number = 1,
backoff: number = 300,
): Promise<Response> {
const retryCodes = [408, 500, 502, 503, 504, 522, 524]; const retryCodes = [408, 500, 502, 503, 504, 522, 524];
let res; let res;
try { try {
@ -45,6 +60,7 @@ class Addon {
} }
return res; return res;
} }
}
module.exports = Addon; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
abstract handleEvent(event: IEvent, parameters: any): Promise<void>;
}

View File

@ -1,15 +1,14 @@
'use strict'; import {
const {
FEATURE_CREATED, FEATURE_CREATED,
FEATURE_UPDATED, FEATURE_UPDATED,
FEATURE_ARCHIVED, FEATURE_ARCHIVED,
FEATURE_REVIVED, FEATURE_REVIVED,
FEATURE_STALE_ON, FEATURE_STALE_ON,
FEATURE_STALE_OFF, FEATURE_STALE_OFF,
} = require('../types/events'); } from '../types/events';
import { IAddonDefinition } from '../types/model';
module.exports = { const dataDogDefinition: IAddonDefinition = {
name: 'datadog', name: 'datadog',
displayName: 'Datadog', displayName: 'Datadog',
description: 'Allows Unleash to post updates to Datadog.', description: 'Allows Unleash to post updates to Datadog.',
@ -22,6 +21,7 @@ module.exports = {
'Default url: https://api.datadoghq.com/api/v1/events. Needs to be changed if your not using the US1 site.', 'Default url: https://api.datadoghq.com/api/v1/events. Needs to be changed if your not using the US1 site.',
type: 'url', type: 'url',
required: false, required: false,
sensitive: false,
}, },
{ {
name: 'apiKey', name: 'apiKey',
@ -50,3 +50,5 @@ module.exports = {
}, },
], ],
}; };
export default dataDogDefinition;

View File

@ -1,31 +1,44 @@
const { FEATURE_CREATED, FEATURE_ARCHIVED } = require('../types/events'); import { FEATURE_CREATED, FEATURE_ARCHIVED } from '../types/events';
import { Logger } from '../logger';
import DatadogAddon from './datadog';
import noLogger from '../../test/fixtures/no-logger';
import { IEvent } from '../types/model';
let fetchRetryCalls: any[] = [];
jest.mock( jest.mock(
'./addon', './addon',
() => () =>
class Addon { class Addon {
logger: Logger;
constructor(definition, { getLogger }) { constructor(definition, { getLogger }) {
this.logger = getLogger('addon/test'); this.logger = getLogger('addon/test');
this.fetchRetryCalls = []; fetchRetryCalls = [];
} }
async fetchRetry(url, options, retries, backoff) { async fetchRetry(url, options, retries, backoff) {
this.fetchRetryCalls.push({ url, options, retries, backoff }); fetchRetryCalls.push({
url,
options,
retries,
backoff,
});
return Promise.resolve({ status: 200 }); return Promise.resolve({ status: 200 });
} }
}, },
); );
const DatadogAddon = require('./datadog');
const noLogger = require('../../test/fixtures/no-logger');
test('Should call datadog webhook', async () => { test('Should call datadog webhook', async () => {
const addon = new DatadogAddon({ const addon = new DatadogAddon({
getLogger: noLogger, getLogger: noLogger,
unleashUrl: 'http://some-url.com', unleashUrl: 'http://some-url.com',
}); });
const event = { const event: IEvent = {
id: 1,
createdAt: new Date(),
type: FEATURE_CREATED, type: FEATURE_CREATED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -40,9 +53,9 @@ test('Should call datadog webhook', async () => {
}; };
await addon.handleEvent(event, parameters); await addon.handleEvent(event, parameters);
expect(addon.fetchRetryCalls.length).toBe(1); expect(fetchRetryCalls.length).toBe(1);
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url); expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot(); expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
}); });
test('Should call datadog webhook for archived toggle', async () => { test('Should call datadog webhook for archived toggle', async () => {
@ -50,7 +63,9 @@ test('Should call datadog webhook for archived toggle', async () => {
getLogger: noLogger, getLogger: noLogger,
unleashUrl: 'http://some-url.com', unleashUrl: 'http://some-url.com',
}); });
const event = { const event: IEvent = {
id: 2,
createdAt: new Date(),
type: FEATURE_ARCHIVED, type: FEATURE_ARCHIVED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -63,7 +78,7 @@ test('Should call datadog webhook for archived toggle', async () => {
}; };
await addon.handleEvent(event, parameters); await addon.handleEvent(event, parameters);
expect(addon.fetchRetryCalls.length).toBe(1); expect(fetchRetryCalls.length).toBe(1);
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url); expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot(); expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
}); });

View File

@ -1,30 +1,30 @@
'use strict'; import YAML from 'js-yaml';
import Addon from './addon';
const YAML = require('js-yaml'); import {
const Addon = require('./addon');
const {
FEATURE_CREATED, FEATURE_CREATED,
FEATURE_UPDATED, FEATURE_UPDATED,
FEATURE_ARCHIVED, FEATURE_ARCHIVED,
FEATURE_REVIVED, FEATURE_REVIVED,
FEATURE_STALE_ON, FEATURE_STALE_ON,
FEATURE_STALE_OFF, FEATURE_STALE_OFF,
} = require('../types/events'); } from '../types/events';
const definition = require('./datadog-definition'); import definition from './datadog-definition';
import { LogProvider } from '../logger';
import { IEvent } from '../types/model';
class DatadogAddon extends Addon { export default class DatadogAddon extends Addon {
constructor(args) { unleashUrl: string;
super(definition, args);
this.unleashUrl = args.unleashUrl; constructor(config: { unleashUrl: string; getLogger: LogProvider }) {
super(definition, config);
this.unleashUrl = config.unleashUrl;
} }
async handleEvent(event, parameters) { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
const { async handleEvent(event: IEvent, parameters: any): Promise<void> {
url = 'https://api.datadoghq.com/api/v1/events', const { url = 'https://api.datadoghq.com/api/v1/events', apiKey } =
apiKey, parameters;
} = parameters;
let text; let text;
if ([FEATURE_ARCHIVED, FEATURE_REVIVED].includes(event.type)) { if ([FEATURE_ARCHIVED, FEATURE_REVIVED].includes(event.type)) {
@ -37,7 +37,7 @@ class DatadogAddon extends Addon {
const { tags: eventTags } = event; const { tags: eventTags } = event;
const tags = const tags =
eventTags && eventTags.map(tag => `${tag.value}:${tag.type}`); eventTags && eventTags.map((tag) => `${tag.value}:${tag.type}`);
const body = { const body = {
text: `%%% \n ${text} \n %%% `, text: `%%% \n ${text} \n %%% `,
title: 'Unleash notification update', title: 'Unleash notification update',
@ -58,12 +58,12 @@ class DatadogAddon extends Addon {
); );
} }
featureLink(event) { featureLink(event: IEvent): string {
const path = event.type === FEATURE_ARCHIVED ? 'archive' : 'features'; const path = event.type === FEATURE_ARCHIVED ? 'archive' : 'features';
return `${this.unleashUrl}/${path}/strategies/${event.data.name}`; return `${this.unleashUrl}/${path}/strategies/${event.data.name}`;
} }
generateStaleText(event) { generateStaleText(event: IEvent): string {
const { createdBy, data, type } = event; const { createdBy, data, type } = event;
const isStale = type === FEATURE_STALE_ON; const isStale = type === FEATURE_STALE_ON;
const feature = `[${data.name}](${this.featureLink(event)})`; const feature = `[${data.name}](${this.featureLink(event)})`;
@ -75,14 +75,14 @@ This was changed by ${createdBy}.`;
return `The feature toggle *${feature}* was *unmarked as stale* by ${createdBy}.`; return `The feature toggle *${feature}* was *unmarked as stale* by ${createdBy}.`;
} }
generateArchivedText(event) { generateArchivedText(event: IEvent): string {
const { createdBy, data, type } = event; const { createdBy, data, type } = event;
const action = type === FEATURE_ARCHIVED ? 'archived' : 'revived'; const action = type === FEATURE_ARCHIVED ? 'archived' : 'revived';
const feature = `[${data.name}](${this.featureLink(event)})`; const feature = `[${data.name}](${this.featureLink(event)})`;
return `The feature toggle *${feature}* was *${action}* by ${createdBy}.`; return `The feature toggle *${feature}* was *${action}* by ${createdBy}.`;
} }
generateText(event) { generateText(event: IEvent): string {
const { createdBy, data, type } = event; const { createdBy, data, type } = event;
const action = this.getAction(type); const action = this.getAction(type);
const feature = `[${data.name}](${this.featureLink(event)})`; const feature = `[${data.name}](${this.featureLink(event)})`;
@ -90,7 +90,7 @@ This was changed by ${createdBy}.`;
const stale = data.stale ? '("stale")' : ''; const stale = data.stale ? '("stale")' : '';
const typeStr = `**Type**: ${data.type}`; const typeStr = `**Type**: ${data.type}`;
const project = `**Project**: ${data.project}`; const project = `**Project**: ${data.project}`;
const strategies = `**Activation strategies**: \`\`\`${YAML.safeDump( const strategies = `**Activation strategies**: \`\`\`${YAML.dump(
data.strategies, data.strategies,
{ skipInvalid: true }, { skipInvalid: true },
)}\`\`\``; )}\`\`\``;
@ -99,7 +99,7 @@ ${enabled}${stale} | ${typeStr} | ${project}
${strategies}`; ${strategies}`;
} }
getAction(type) { getAction(type: string): string {
switch (type) { switch (type) {
case FEATURE_CREATED: case FEATURE_CREATED:
return 'created'; return 'created';
@ -110,5 +110,3 @@ ${strategies}`;
} }
} }
} }
module.exports = DatadogAddon;

View File

@ -1,8 +0,0 @@
const webhook = require('./webhook');
const slackAddon = require('./slack');
const teamsAddon = require('./teams');
const datadogAddon = require('./datadog');
const addons = [webhook, slackAddon, teamsAddon, datadogAddon];
module.exports = addons;

27
src/lib/addons/index.ts Normal file
View File

@ -0,0 +1,27 @@
import Webhook from './webhook';
import SlackAddon from './slack';
import TeamsAddon from './teams';
import DatadogAddon from './datadog';
import Addon from './addon';
import { LogProvider } from '../logger';
export interface IAddonProviders {
[key: string]: Addon;
}
export const getAddons: (args: {
getLogger: LogProvider;
unleashUrl: string;
}) => IAddonProviders = ({ getLogger, unleashUrl }) => {
const addons = [
new Webhook({ getLogger }),
new SlackAddon({ getLogger, unleashUrl }),
new TeamsAddon({ getLogger, unleashUrl }),
new DatadogAddon({ getLogger, unleashUrl }),
];
return addons.reduce((map, addon) => {
// eslint-disable-next-line no-param-reassign
map[addon.name] = addon;
return map;
}, {});
};

View File

@ -1,52 +0,0 @@
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
} = require('../types/events');
module.exports = {
name: 'jira-comment',
displayName: 'Jira Commenter',
description: 'Allows Unleash to post comments to JIRA issues',
parameters: [
{
name: 'baseUrl',
displayName: 'Jira base url e.g. https://myjira.atlassian.net',
type: 'url',
required: true,
},
{
name: 'apiKey',
displayName: 'Jira API token',
description:
'Used to authenticate against JIRA REST api, needs to be for a user with comment access to issues. ' +
'Add a new key at https://id.atlassian.com/manage-profile/security/api-tokens when logged in as the user you want Unleash to use',
type: 'text',
required: true,
sensitive: true,
},
{
name: 'user',
displayName: 'JIRA username',
description:
'Used together with API key to authenticate against JIRA. Since Unleash adds comments as this user, it is a good idea to create a separate user',
type: 'text',
required: true,
},
],
events: [
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
],
tagTypes: [
{
name: 'jira',
description:
'Jira tag used by the jira addon to specify the JIRA issue to comment on',
icon: 'J',
},
],
};

View File

@ -1,116 +0,0 @@
'use strict';
const Addon = require('./addon');
const definition = require('./jira-comment-definition');
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_REVIVED,
FEATURE_ARCHIVED,
} = require('../types/events');
class JiraAddon extends Addon {
constructor(args) {
super(definition, args);
this.unleashUrl = args.unleashUrl;
}
async handleEvent(event, parameters) {
const { type: eventName } = event;
const { baseUrl, user, apiKey } = parameters;
const issuesToPostTo = this.findJiraTag(event);
const action = this.getAction(eventName);
const body = this.formatBody(event, action);
const requests = issuesToPostTo.map(async issueTag => {
const issue = issueTag.value;
const issueUrl = `${baseUrl}/rest/api/3/issue/${issue}/comment`;
const requestOpts = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: this.buildAuthHeader(user, apiKey),
},
body,
};
return this.fetchRetry(issueUrl, requestOpts);
});
const results = await Promise.all(requests);
const codes = results.map(res => res.status).join(', ');
this.logger.info(`Handled event ${event.type}. Status codes=${codes}`);
}
getAction(eventName) {
switch (eventName) {
case FEATURE_CREATED:
return 'created';
case FEATURE_UPDATED:
return 'updated';
case FEATURE_ARCHIVED:
return 'archived';
case FEATURE_REVIVED:
return 'revived';
default:
return 'unknown';
}
}
encode(str) {
return Buffer.from(str, 'utf-8').toString('base64');
}
formatBody(event, action) {
const featureName = event.data.name;
const { createdBy } = event;
return JSON.stringify({
body: {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: `Feature toggle "${featureName}" was ${action} by ${createdBy}`,
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'To see what happened visit Unleash',
marks: [
{
type: 'link',
attrs: {
href: `${this.unleashUrl}/features/strategies/${featureName}`,
title: 'Visit Unleash Admin UI',
},
},
],
},
],
},
],
},
});
}
buildAuthHeader(userName, apiKey) {
const base64 = this.encode(`${userName}:${apiKey}`);
return `Basic ${base64}`;
}
findJiraTag({ tags }) {
return tags.filter(tag => tag.type === 'jira');
}
}
module.exports = JiraAddon;

View File

@ -1,225 +0,0 @@
const fetchMock = require('fetch-mock').sandbox();
const noLogger = require('../../test/fixtures/no-logger');
jest.mock('node-fetch', () => fetchMock);
const addonMocked = require('./addon');
jest.mock('./addon', () => addonMocked);
const JiraAddon = require('./jira-comment');
const { addonDefinitionSchema } = require('./addon-schema');
beforeEach(() => {
fetchMock.restore();
fetchMock.reset();
});
test('Addon definition should validate', () => {
const { error } = addonDefinitionSchema.validate(JiraAddon.definition);
expect(error).toBe(undefined);
});
test('An update event should post updated comment with updater and link back to issue', async () => {
const jiraIssue = 'TEST-1';
const jiraBaseUrl = 'https://test.jira.com';
const addon = new JiraAddon({
getLogger: noLogger,
unleashUrl: 'https://test.unleash.com',
});
fetchMock.mock(
{ url: `${jiraBaseUrl}/rest/api/3/issue/${jiraIssue}/comment` },
201,
);
await addon.handleEvent(
{
createdBy: 'test@test.com',
type: 'feature-updated',
data: {
name: 'feature.toggle',
},
tags: [{ type: 'jira', value: jiraIssue }],
},
{
baseUrl: jiraBaseUrl,
user: 'test@test.com',
apiKey: 'test',
},
);
expect(fetchMock.calls(true).length).toBe(1);
expect(fetchMock.done()).toBe(true);
});
test('An event that is tagged with two tags causes two updates', async () => {
const jiraBaseUrl = 'https://test.jira.com';
const addon = new JiraAddon({
getLogger: noLogger,
unleashUrl: 'https://test.unleash.com',
});
fetchMock.mock(
{
name: 'test-1',
url: `${jiraBaseUrl}/rest/api/3/issue/TEST-1/comment`,
},
{
status: 201,
statusText: 'Accepted',
},
);
fetchMock.mock(
{
name: 'test-2',
url: `${jiraBaseUrl}/rest/api/3/issue/TEST-2/comment`,
},
{
status: 201,
statusText: 'Accepted',
},
);
await addon.handleEvent(
{
createdBy: 'test@test.com',
type: 'feature-updated',
data: {
name: 'feature.toggle',
},
tags: [
{ type: 'jira', value: 'TEST-1' },
{ type: 'jira', value: 'TEST-2' },
],
},
{
baseUrl: 'https://test.jira.com',
user: 'test@test.com',
apiKey: 'test',
},
);
expect(fetchMock.done()).toBe(true);
});
test('An event with no jira tags will be ignored', async () => {
const addon = new JiraAddon({
getLogger: noLogger,
unleashUrl: 'https://test.unleash.com',
});
fetchMock.any(200);
await addon.handleEvent(
{
createdBy: 'test@test.com',
type: 'feature-updated',
data: {
name: 'feature.toggle',
},
tags: [],
},
{
baseUrl: 'https://test.jira.com',
user: 'test@test.com',
apiKey: 'test',
},
);
expect(fetchMock.calls().length).toBe(0); // No calls
});
test('Retries if error code in the 500s', async () => {
const jiraBaseUrl = 'https://test.jira.com';
const jiraIssue = 'TEST-1';
jest.useFakeTimers('modern');
const addon = new JiraAddon({
getLogger: noLogger,
unleashUrl: 'https://test.unleash.com',
});
fetchMock
.once(
{
name: 'rejection',
type: 'POST',
url: 'begin:https://test.jira.com',
},
500,
)
.mock(
{
name: 'acceptance',
type: 'POST',
url: 'begin:https://test.jira.com',
},
201,
);
await addon.handleEvent(
{
type: 'feature-updated',
createdBy: 'test@test.com',
data: {
name: 'feature.toggle',
},
tags: [{ type: 'jira', value: jiraIssue }],
},
{
baseUrl: jiraBaseUrl,
user: 'test@test.com',
apiKey: 'test',
},
);
jest.advanceTimersByTime(1000);
expect(fetchMock.done()).toBe(true);
});
test('Only retries once', async () => {
const jiraBaseUrl = 'https://test.jira.com';
const jiraIssue = 'TEST-1';
jest.useFakeTimers('modern');
const addon = new JiraAddon({
getLogger: noLogger,
unleashUrl: 'https://test.unleash.com',
});
fetchMock.mock('*', 500, { repeat: 2 });
await addon.handleEvent(
{
type: 'feature-updated',
createdBy: 'test@test.com',
data: {
name: 'feature.toggle',
},
tags: [{ type: 'jira', value: jiraIssue }],
},
{
baseUrl: jiraBaseUrl,
user: 'test@test.com',
apiKey: 'test',
},
);
jest.advanceTimersByTime(1000);
expect(fetchMock.done()).toBe(true);
});
test('Does not retry if a 4xx error is given', async () => {
const jiraBaseUrl = 'https://test.jira.com';
const jiraIssue = 'TEST-1';
const addon = new JiraAddon({
getLogger: noLogger,
unleashUrl: 'https://test.unleash.com',
});
fetchMock.once(
{
name: 'rejection',
type: 'POST',
url: 'begin:https://test.jira.com',
},
400,
);
await addon.handleEvent(
{
type: 'feature-updated',
createdBy: 'test@test.com',
data: {
name: 'feature.toggle',
},
tags: [{ type: 'jira', value: jiraIssue }],
},
{
baseUrl: jiraBaseUrl,
user: 'test@test.com',
apiKey: 'test',
},
);
expect(fetchMock.done()).toBe(true);
});

View File

@ -1,15 +1,14 @@
'use strict'; import {
const {
FEATURE_CREATED, FEATURE_CREATED,
FEATURE_UPDATED, FEATURE_UPDATED,
FEATURE_ARCHIVED, FEATURE_ARCHIVED,
FEATURE_REVIVED, FEATURE_REVIVED,
FEATURE_STALE_ON, FEATURE_STALE_ON,
FEATURE_STALE_OFF, FEATURE_STALE_OFF,
} = require('../types/events'); } from '../types/events';
import { IAddonDefinition } from '../types/model';
module.exports = { const slackDefinition: IAddonDefinition = {
name: 'slack', name: 'slack',
displayName: 'Slack', displayName: 'Slack',
description: 'Allows Unleash to post updates to Slack.', description: 'Allows Unleash to post updates to Slack.',
@ -30,6 +29,7 @@ module.exports = {
'The username to use when posting messages to slack. Defaults to "Unleash".', 'The username to use when posting messages to slack. Defaults to "Unleash".',
type: 'text', type: 'text',
required: false, required: false,
sensitive: false,
}, },
{ {
name: 'emojiIcon', name: 'emojiIcon',
@ -39,6 +39,7 @@ module.exports = {
'The emoji_icon to use when posting messages to slack. Defaults to ":unleash:".', 'The emoji_icon to use when posting messages to slack. Defaults to ":unleash:".',
type: 'text', type: 'text',
required: false, required: false,
sensitive: false,
}, },
{ {
name: 'defaultChannel', name: 'defaultChannel',
@ -47,6 +48,7 @@ module.exports = {
'Default channel to post updates to if not specified in the slack-tag', 'Default channel to post updates to if not specified in the slack-tag',
type: 'text', type: 'text',
required: true, required: true,
sensitive: false,
}, },
], ],
events: [ events: [
@ -66,3 +68,5 @@ module.exports = {
}, },
], ],
}; };
export default slackDefinition;

View File

@ -1,31 +1,44 @@
const { FEATURE_CREATED, FEATURE_ARCHIVED } = require('../types/events'); import { FEATURE_CREATED, FEATURE_ARCHIVED } from '../types/events';
import { Logger } from '../logger';
import SlackAddon from './slack';
import noLogger from '../../test/fixtures/no-logger';
import { IEvent } from '../types/model';
let fetchRetryCalls: any[] = [];
jest.mock( jest.mock(
'./addon', './addon',
() => () =>
class Addon { class Addon {
logger: Logger;
constructor(definition, { getLogger }) { constructor(definition, { getLogger }) {
this.logger = getLogger('addon/test'); this.logger = getLogger('addon/test');
this.fetchRetryCalls = []; fetchRetryCalls = [];
} }
async fetchRetry(url, options, retries, backoff) { async fetchRetry(url, options, retries, backoff) {
this.fetchRetryCalls.push({ url, options, retries, backoff }); fetchRetryCalls.push({
url,
options,
retries,
backoff,
});
return Promise.resolve({ status: 200 }); return Promise.resolve({ status: 200 });
} }
}, },
); );
const SlackAddon = require('./slack');
const noLogger = require('../../test/fixtures/no-logger');
test('Should call slack webhook', async () => { test('Should call slack webhook', async () => {
const addon = new SlackAddon({ const addon = new SlackAddon({
getLogger: noLogger, getLogger: noLogger,
unleashUrl: 'http://some-url.com', unleashUrl: 'http://some-url.com',
}); });
const event = { const event: IEvent = {
id: 1,
createdAt: new Date(),
type: FEATURE_CREATED, type: FEATURE_CREATED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -40,9 +53,9 @@ test('Should call slack webhook', async () => {
}; };
await addon.handleEvent(event, parameters); await addon.handleEvent(event, parameters);
expect(addon.fetchRetryCalls.length).toBe(1); expect(fetchRetryCalls.length).toBe(1);
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url); expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot(); expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
}); });
test('Should call slack webhook for archived toggle', async () => { test('Should call slack webhook for archived toggle', async () => {
@ -50,7 +63,9 @@ test('Should call slack webhook for archived toggle', async () => {
getLogger: noLogger, getLogger: noLogger,
unleashUrl: 'http://some-url.com', unleashUrl: 'http://some-url.com',
}); });
const event = { const event: IEvent = {
id: 2,
createdAt: new Date(),
type: FEATURE_ARCHIVED, type: FEATURE_ARCHIVED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -63,9 +78,9 @@ test('Should call slack webhook for archived toggle', async () => {
}; };
await addon.handleEvent(event, parameters); await addon.handleEvent(event, parameters);
expect(addon.fetchRetryCalls.length).toBe(1); expect(fetchRetryCalls.length).toBe(1);
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url); expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot(); expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
}); });
test('Should use default channel', async () => { test('Should use default channel', async () => {
@ -73,7 +88,9 @@ test('Should use default channel', async () => {
getLogger: noLogger, getLogger: noLogger,
unleashUrl: 'http://some-url.com', unleashUrl: 'http://some-url.com',
}); });
const event = { const event: IEvent = {
id: 3,
createdAt: new Date(),
type: FEATURE_CREATED, type: FEATURE_CREATED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -90,7 +107,7 @@ test('Should use default channel', async () => {
await addon.handleEvent(event, parameters); await addon.handleEvent(event, parameters);
const req = JSON.parse(addon.fetchRetryCalls[0].options.body); const req = JSON.parse(fetchRetryCalls[0].options.body);
expect(req.channel).toBe('#some-channel'); expect(req.channel).toBe('#some-channel');
}); });
@ -100,7 +117,9 @@ test('Should override default channel with data from tag', async () => {
getLogger: noLogger, getLogger: noLogger,
unleashUrl: 'http://some-url.com', unleashUrl: 'http://some-url.com',
}); });
const event = { const event: IEvent = {
id: 4,
createdAt: new Date(),
type: FEATURE_CREATED, type: FEATURE_CREATED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -123,7 +142,7 @@ test('Should override default channel with data from tag', async () => {
await addon.handleEvent(event, parameters); await addon.handleEvent(event, parameters);
const req = JSON.parse(addon.fetchRetryCalls[0].options.body); const req = JSON.parse(fetchRetryCalls[0].options.body);
expect(req.channel).toBe('#another-channel'); expect(req.channel).toBe('#another-channel');
}); });
@ -133,7 +152,9 @@ test('Should post to all channels in tags', async () => {
getLogger: noLogger, getLogger: noLogger,
unleashUrl: 'http://some-url.com', unleashUrl: 'http://some-url.com',
}); });
const event = { const event: IEvent = {
id: 5,
createdAt: new Date(),
type: FEATURE_CREATED, type: FEATURE_CREATED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -160,10 +181,10 @@ test('Should post to all channels in tags', async () => {
await addon.handleEvent(event, parameters); await addon.handleEvent(event, parameters);
const req1 = JSON.parse(addon.fetchRetryCalls[0].options.body); const req1 = JSON.parse(fetchRetryCalls[0].options.body);
const req2 = JSON.parse(addon.fetchRetryCalls[1].options.body); const req2 = JSON.parse(fetchRetryCalls[1].options.body);
expect(addon.fetchRetryCalls.length).toBe(2); expect(fetchRetryCalls).toHaveLength(2);
expect(req1.channel).toBe('#another-channel-1'); expect(req1.channel).toBe('#another-channel-1');
expect(req2.channel).toBe('#another-channel-2'); expect(req2.channel).toBe('#another-channel-2');
}); });

View File

@ -1,7 +1,8 @@
'use strict'; import YAML from 'js-yaml';
import Addon from './addon';
const YAML = require('js-yaml'); import slackDefinition from './slack-definition';
const Addon = require('./addon'); import { IAddonConfig, IEvent } from '../types/model';
const { const {
FEATURE_CREATED, FEATURE_CREATED,
@ -12,15 +13,16 @@ const {
FEATURE_STALE_OFF, FEATURE_STALE_OFF,
} = require('../types/events'); } = require('../types/events');
const definition = require('./slack-definition'); export default class SlackAddon extends Addon {
unleashUrl: string;
class SlackAddon extends Addon { constructor(args: IAddonConfig) {
constructor(args) { super(slackDefinition, args);
super(definition, args);
this.unleashUrl = args.unleashUrl; this.unleashUrl = args.unleashUrl;
} }
async handleEvent(event, parameters) { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async handleEvent(event: IEvent, parameters: any): Promise<void> {
const { const {
url, url,
defaultChannel, defaultChannel,
@ -44,7 +46,7 @@ class SlackAddon extends Addon {
text = this.generateText(event); text = this.generateText(event);
} }
const requests = slackChannels.map(channel => { const requests = slackChannels.map((channel) => {
const body = { const body = {
username, username,
icon_emoji: iconEmoji, // eslint-disable-line camelcase icon_emoji: iconEmoji, // eslint-disable-line camelcase
@ -76,20 +78,25 @@ class SlackAddon extends Addon {
}); });
const results = await Promise.all(requests); const results = await Promise.all(requests);
const codes = results.map(res => res.status).join(', '); const codes = results.map((res) => res.status).join(', ');
this.logger.info(`Handled event ${event.type}. Status codes=${codes}`); this.logger.info(`Handled event ${event.type}. Status codes=${codes}`);
} }
featureLink(event) { featureLink(event: IEvent): string {
const path = event.type === FEATURE_ARCHIVED ? 'archive' : 'features'; const path = event.type === FEATURE_ARCHIVED ? 'archive' : 'features';
return `${this.unleashUrl}/${path}/strategies/${event.data.name}`; return `${this.unleashUrl}/${path}/strategies/${event.data.name}`;
} }
findSlackChannels({ tags = [] }) { findSlackChannels({ tags }: Pick<IEvent, 'tags'>): string[] {
return tags.filter(tag => tag.type === 'slack').map(t => t.value); if (tags) {
return tags
.filter((tag) => tag.type === 'slack')
.map((t) => t.value);
}
return [];
} }
generateStaleText(event) { generateStaleText(event: IEvent): string {
const { createdBy, data, type } = event; const { createdBy, data, type } = event;
const isStale = type === FEATURE_STALE_ON; const isStale = type === FEATURE_STALE_ON;
const feature = `<${this.featureLink(event)}|${data.name}>`; const feature = `<${this.featureLink(event)}|${data.name}>`;
@ -101,14 +108,14 @@ This was changed by ${createdBy}.`;
return `The feature toggle *${feature}* was *unmarked as stale* by ${createdBy}.`; return `The feature toggle *${feature}* was *unmarked as stale* by ${createdBy}.`;
} }
generateArchivedText(event) { generateArchivedText(event: IEvent): string {
const { createdBy, data, type } = event; const { createdBy, data, type } = event;
const action = type === FEATURE_ARCHIVED ? 'archived' : 'revived'; const action = type === FEATURE_ARCHIVED ? 'archived' : 'revived';
const feature = `<${this.featureLink(event)}|${data.name}>`; const feature = `<${this.featureLink(event)}|${data.name}>`;
return `The feature toggle *${feature}* was *${action}* by ${createdBy}.`; return `The feature toggle *${feature}* was *${action}* by ${createdBy}.`;
} }
generateText(event) { generateText(event: IEvent): string {
const { createdBy, data, type } = event; const { createdBy, data, type } = event;
const action = this.getAction(type); const action = this.getAction(type);
const feature = `<${this.featureLink(event)}|${data.name}>`; const feature = `<${this.featureLink(event)}|${data.name}>`;
@ -125,7 +132,7 @@ ${enabled}${stale} | ${typeStr} | ${project}
${strategies}`; ${strategies}`;
} }
getAction(type) { getAction(type: string): string {
switch (type) { switch (type) {
case FEATURE_CREATED: case FEATURE_CREATED:
return 'created'; return 'created';

View File

@ -1,15 +1,14 @@
'use strict'; import {
const {
FEATURE_CREATED, FEATURE_CREATED,
FEATURE_UPDATED, FEATURE_UPDATED,
FEATURE_ARCHIVED, FEATURE_ARCHIVED,
FEATURE_REVIVED, FEATURE_REVIVED,
FEATURE_STALE_ON, FEATURE_STALE_ON,
FEATURE_STALE_OFF, FEATURE_STALE_OFF,
} = require('../types/events'); } from '../types/events';
import { IAddonDefinition } from '../types/model';
module.exports = { const teamsDefinition: IAddonDefinition = {
name: 'teams', name: 'teams',
displayName: 'Microsoft Teams', displayName: 'Microsoft Teams',
description: 'Allows Unleash to post updates to Microsoft Teams.', description: 'Allows Unleash to post updates to Microsoft Teams.',
@ -32,3 +31,5 @@ module.exports = {
FEATURE_STALE_OFF, FEATURE_STALE_OFF,
], ],
}; };
export default teamsDefinition;

View File

@ -1,31 +1,45 @@
const { FEATURE_CREATED, FEATURE_ARCHIVED } = require('../types/events'); import { Logger } from '../logger';
import { FEATURE_CREATED, FEATURE_ARCHIVED } from '../types/events';
import TeamsAddon from './teams';
import noLogger from '../../test/fixtures/no-logger';
import { IEvent } from '../types/model';
let fetchRetryCalls: any[];
jest.mock( jest.mock(
'./addon', './addon',
() => () =>
class Addon { class Addon {
logger: Logger;
constructor(definition, { getLogger }) { constructor(definition, { getLogger }) {
this.logger = getLogger('addon/test'); this.logger = getLogger('addon/test');
this.fetchRetryCalls = []; fetchRetryCalls = [];
} }
async fetchRetry(url, options, retries, backoff) { async fetchRetry(url, options, retries, backoff) {
this.fetchRetryCalls.push({ url, options, retries, backoff }); fetchRetryCalls.push({
url,
options,
retries,
backoff,
});
return Promise.resolve({ status: 200 }); return Promise.resolve({ status: 200 });
} }
}, },
); );
const TeamsAddon = require('./teams');
const noLogger = require('../../test/fixtures/no-logger');
test('Should call teams webhook', async () => { test('Should call teams webhook', async () => {
const addon = new TeamsAddon({ const addon = new TeamsAddon({
getLogger: noLogger, getLogger: noLogger,
unleashUrl: 'http://some-url.com', unleashUrl: 'http://some-url.com',
}); });
const event = { const event: IEvent = {
id: 1,
createdAt: new Date(),
type: FEATURE_CREATED, type: FEATURE_CREATED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -40,9 +54,9 @@ test('Should call teams webhook', async () => {
}; };
await addon.handleEvent(event, parameters); await addon.handleEvent(event, parameters);
expect(addon.fetchRetryCalls.length).toBe(1); expect(fetchRetryCalls.length).toBe(1);
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url); expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot(); expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
}); });
test('Should call teams webhook for archived toggle', async () => { test('Should call teams webhook for archived toggle', async () => {
@ -51,6 +65,8 @@ test('Should call teams webhook for archived toggle', async () => {
unleashUrl: 'http://some-url.com', unleashUrl: 'http://some-url.com',
}); });
const event = { const event = {
id: 1,
createdAt: new Date(),
type: FEATURE_ARCHIVED, type: FEATURE_ARCHIVED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -63,7 +79,7 @@ test('Should call teams webhook for archived toggle', async () => {
}; };
await addon.handleEvent(event, parameters); await addon.handleEvent(event, parameters);
expect(addon.fetchRetryCalls.length).toBe(1); expect(fetchRetryCalls.length).toBe(1);
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url); expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot(); expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
}); });

View File

@ -1,26 +1,29 @@
'use strict'; import YAML from 'js-yaml';
import Addon from './addon';
const YAML = require('js-yaml'); import {
const Addon = require('./addon');
const {
FEATURE_CREATED, FEATURE_CREATED,
FEATURE_UPDATED, FEATURE_UPDATED,
FEATURE_ARCHIVED, FEATURE_ARCHIVED,
FEATURE_REVIVED, FEATURE_REVIVED,
FEATURE_STALE_ON, FEATURE_STALE_ON,
FEATURE_STALE_OFF, FEATURE_STALE_OFF,
} = require('../types/events'); } from '../types/events';
import { LogProvider } from '../logger';
const definition = require('./teams-definition'); import teamsDefinition from './teams-definition';
import { IEvent } from '../types/model';
class TeamsAddon extends Addon { export default class TeamsAddon extends Addon {
constructor(args) { unleashUrl: string;
super(definition, args);
constructor(args: { unleashUrl: string; getLogger: LogProvider }) {
super(teamsDefinition, args);
this.unleashUrl = args.unleashUrl; this.unleashUrl = args.unleashUrl;
} }
async handleEvent(event, parameters) { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async handleEvent(event: IEvent, parameters: any): Promise<void> {
const { url } = parameters; const { url } = parameters;
const { createdBy, data, type } = event; const { createdBy, data, type } = event;
let text = ''; let text = '';
@ -82,12 +85,12 @@ class TeamsAddon extends Addon {
); );
} }
featureLink(event) { featureLink(event: IEvent): string {
const path = event.type === FEATURE_ARCHIVED ? 'archive' : 'features'; const path = event.type === FEATURE_ARCHIVED ? 'archive' : 'features';
return `${this.unleashUrl}/${path}/strategies/${event.data.name}`; return `${this.unleashUrl}/${path}/strategies/${event.data.name}`;
} }
generateStaleText(event) { generateStaleText(event: IEvent): string {
const { data, type } = event; const { data, type } = event;
const isStale = type === FEATURE_STALE_ON; const isStale = type === FEATURE_STALE_ON;
if (isStale) { if (isStale) {
@ -96,13 +99,13 @@ class TeamsAddon extends Addon {
return `The feature toggle *${data.name}* was *unmarked* as stale`; return `The feature toggle *${data.name}* was *unmarked* as stale`;
} }
generateArchivedText(event) { generateArchivedText(event: IEvent): string {
const { data, type } = event; const { data, type } = event;
const action = type === FEATURE_ARCHIVED ? 'archived' : 'revived'; const action = type === FEATURE_ARCHIVED ? 'archived' : 'revived';
return `The feature toggle *${data.name}* was *${action}*`; return `The feature toggle *${data.name}* was *${action}*`;
} }
generateText(event) { generateText(event: IEvent): string {
const { data } = event; const { data } = event;
const typeStr = `*Type*: ${data.type}`; const typeStr = `*Type*: ${data.type}`;
const project = `*Project*: ${data.project}`; const project = `*Project*: ${data.project}`;
@ -113,7 +116,7 @@ class TeamsAddon extends Addon {
return `Feature toggle ${data.name} | ${typeStr} | ${project} <br /> ${strategies}`; return `Feature toggle ${data.name} | ${typeStr} | ${project} <br /> ${strategies}`;
} }
getAction(type) { getAction(type: string): string {
switch (type) { switch (type) {
case FEATURE_CREATED: case FEATURE_CREATED:
return 'Create'; return 'Create';
@ -124,5 +127,3 @@ class TeamsAddon extends Addon {
} }
} }
} }
module.exports = TeamsAddon;

View File

@ -1,13 +1,14 @@
const { import {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED, FEATURE_ARCHIVED,
FEATURE_CREATED,
FEATURE_REVIVED, FEATURE_REVIVED,
FEATURE_STALE_ON,
FEATURE_STALE_OFF, FEATURE_STALE_OFF,
} = require('../types/events'); FEATURE_STALE_ON,
FEATURE_UPDATED,
} from '../types/events';
import { IAddonDefinition } from '../types/model';
module.exports = { const webhookDefinition: IAddonDefinition = {
name: 'webhook', name: 'webhook',
displayName: 'Webhook', displayName: 'Webhook',
description: description:
@ -31,6 +32,7 @@ module.exports = {
'(Optional) The Content-Type header to use. Defaults to "application/json".', '(Optional) The Content-Type header to use. Defaults to "application/json".',
type: 'text', type: 'text',
required: false, required: false,
sensitive: false,
}, },
{ {
name: 'bodyTemplate', name: 'bodyTemplate',
@ -45,6 +47,7 @@ module.exports = {
"(Optional) You may format the body using a mustache template. If you don't specify anything, the format will similar to the events format (https://docs.getunleash.io/docs/api/admin/events)", "(Optional) You may format the body using a mustache template. If you don't specify anything, the format will similar to the events format (https://docs.getunleash.io/docs/api/admin/events)",
type: 'textfield', type: 'textfield',
required: false, required: false,
sensitive: false,
}, },
], ],
events: [ events: [
@ -56,3 +59,5 @@ module.exports = {
FEATURE_STALE_OFF, FEATURE_STALE_OFF,
], ],
}; };
export default webhookDefinition;

View File

@ -1,28 +1,42 @@
const { FEATURE_CREATED } = require('../types/events'); import { Logger } from '../logger';
import { FEATURE_CREATED } from '../types/events';
import WebhookAddon from './webhook';
import noLogger from '../../test/fixtures/no-logger';
import { IEvent } from '../types/model';
let fetchRetryCalls: any[] = [];
jest.mock( jest.mock(
'./addon', './addon',
() => () =>
class Addon { class Addon {
logger: Logger;
constructor(definition, { getLogger }) { constructor(definition, { getLogger }) {
this.logger = getLogger('addon/test'); this.logger = getLogger('addon/test');
this.fetchRetryCalls = []; fetchRetryCalls = [];
} }
async fetchRetry(url, options, retries, backoff) { async fetchRetry(url, options, retries, backoff) {
this.fetchRetryCalls.push({ url, options, retries, backoff }); fetchRetryCalls.push({
url,
options,
retries,
backoff,
});
return Promise.resolve({ status: 200 }); return Promise.resolve({ status: 200 });
} }
}, },
); );
const WebhookAddon = require('./webhook');
const noLogger = require('../../test/fixtures/no-logger');
test('Should handle event without "bodyTemplate"', () => { test('Should handle event without "bodyTemplate"', () => {
const addon = new WebhookAddon({ getLogger: noLogger }); const addon = new WebhookAddon({ getLogger: noLogger });
const event = { const event: IEvent = {
id: 1,
createdAt: new Date(),
type: FEATURE_CREATED, type: FEATURE_CREATED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -37,14 +51,16 @@ test('Should handle event without "bodyTemplate"', () => {
}; };
addon.handleEvent(event, parameters); addon.handleEvent(event, parameters);
expect(addon.fetchRetryCalls.length).toBe(1); expect(fetchRetryCalls.length).toBe(1);
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url); expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(addon.fetchRetryCalls[0].options.body).toBe(JSON.stringify(event)); expect(fetchRetryCalls[0].options.body).toBe(JSON.stringify(event));
}); });
test('Should format event with "bodyTemplate"', () => { test('Should format event with "bodyTemplate"', () => {
const addon = new WebhookAddon({ getLogger: noLogger }); const addon = new WebhookAddon({ getLogger: noLogger });
const event = { const event: IEvent = {
id: 1,
createdAt: new Date(),
type: FEATURE_CREATED, type: FEATURE_CREATED,
createdBy: 'some@user.com', createdBy: 'some@user.com',
data: { data: {
@ -61,8 +77,8 @@ test('Should format event with "bodyTemplate"', () => {
}; };
addon.handleEvent(event, parameters); addon.handleEvent(event, parameters);
const call = addon.fetchRetryCalls[0]; const call = fetchRetryCalls[0];
expect(addon.fetchRetryCalls.length).toBe(1); expect(fetchRetryCalls.length).toBe(1);
expect(call.url).toBe(parameters.url); expect(call.url).toBe(parameters.url);
expect(call.options.headers['Content-Type']).toBe('text/plain'); expect(call.options.headers['Content-Type']).toBe('text/plain');
expect(call.options.body).toBe('feature-created on toggle some-toggle'); expect(call.options.body).toBe('feature-created on toggle some-toggle');

View File

@ -1,15 +1,16 @@
'use strict'; import Mustache from 'mustache';
import Addon from './addon';
import definition from './webhook-definition';
import { LogProvider } from '../logger';
import { IEvent } from '../types/model';
const Mustache = require('mustache'); export default class Webhook extends Addon {
const Addon = require('./addon'); constructor(args: { getLogger: LogProvider }) {
const definition = require('./webhook-definition');
class Webhook extends Addon {
constructor(args) {
super(definition, args); super(definition, args);
} }
async handleEvent(event, parameters) { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async handleEvent(event: IEvent, parameters: any): Promise<void> {
const { url, bodyTemplate, contentType } = parameters; const { url, bodyTemplate, contentType } = parameters;
const context = { const context = {
event, event,
@ -35,5 +36,3 @@ class Webhook extends Addon {
); );
} }
} }
module.exports = Webhook;

View File

@ -1,7 +1,7 @@
import { publicFolder } from 'unleash-frontend'; import { publicFolder } from 'unleash-frontend';
import fs from 'fs'; import fs from 'fs';
import EventEmitter from 'events'; import EventEmitter from 'events';
import express, { Application } from 'express'; import express, { Application, RequestHandler } from 'express';
import cors from 'cors'; import cors from 'cors';
import compression from 'compression'; import compression from 'compression';
import favicon from 'serve-favicon'; import favicon from 'serve-favicon';
@ -14,7 +14,6 @@ import apiTokenMiddleware from './middleware/api-token-middleware';
import { IUnleashServices } from './types/services'; import { IUnleashServices } from './types/services';
import { IAuthType, IUnleashConfig } from './types/option'; import { IAuthType, IUnleashConfig } from './types/option';
import { IUnleashStores } from './types/stores'; import { IUnleashStores } from './types/stores';
import unleashDbSession from './middleware/session-db';
import IndexRouter from './routes'; import IndexRouter from './routes';
@ -23,6 +22,7 @@ import demoAuthentication from './middleware/demo-authentication';
import ossAuthentication from './middleware/oss-authentication'; import ossAuthentication from './middleware/oss-authentication';
import noAuthentication from './middleware/no-authentication'; import noAuthentication from './middleware/no-authentication';
import secureHeaders from './middleware/secure-headers'; import secureHeaders from './middleware/secure-headers';
import { rewriteHTML } from './util/rewriteHTML'; import { rewriteHTML } from './util/rewriteHTML';
export default function getApp( export default function getApp(
@ -30,6 +30,7 @@ export default function getApp(
stores: IUnleashStores, stores: IUnleashStores,
services: IUnleashServices, services: IUnleashServices,
eventBus?: EventEmitter, eventBus?: EventEmitter,
unleashSession?: RequestHandler,
): Application { ): Application {
const app = express(); const app = express();
@ -63,7 +64,9 @@ export default function getApp(
app.use(compression()); app.use(compression());
app.use(cookieParser()); app.use(cookieParser());
app.use(express.json({ strict: false })); app.use(express.json({ strict: false }));
app.use(unleashDbSession(config, stores)); if (unleashSession) {
app.use(unleashSession);
}
app.use(secureHeaders(config)); app.use(secureHeaders(config));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(favicon(path.join(publicFolder, 'favicon.ico'))); app.use(favicon(path.join(publicFolder, 'favicon.ico')));
@ -76,7 +79,7 @@ export default function getApp(
switch (config.authentication.type) { switch (config.authentication.type) {
case IAuthType.OPEN_SOURCE: { case IAuthType.OPEN_SOURCE: {
app.use(baseUriPath, apiTokenMiddleware(config, services)); app.use(baseUriPath, apiTokenMiddleware(config, services));
ossAuthentication(app, config); ossAuthentication(app, config.server.baseUriPath);
break; break;
} }
case IAuthType.ENTERPRISE: { case IAuthType.ENTERPRISE: {

View File

@ -48,7 +48,7 @@ function safeBoolean(envVar, defaultVal) {
} }
function mergeAll<T>(objects: Partial<T>[]): T { function mergeAll<T>(objects: Partial<T>[]): T {
return merge.all<T>(objects.filter(i => i)); return merge.all<T>(objects.filter((i) => i));
} }
const defaultDbOptions: IDBOption = { const defaultDbOptions: IDBOption = {

View File

@ -2,6 +2,13 @@ import { EventEmitter } from 'events';
import { Knex } from 'knex'; import { Knex } from 'knex';
import metricsHelper from '../util/metrics-helper'; import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { Logger } from '../logger';
import {
IAccessStore,
IRole,
IUserPermission,
IUserRole,
} from '../types/stores/access-store';
const T = { const T = {
ROLE_USER: 'role_user', ROLE_USER: 'role_user',
@ -9,26 +16,8 @@ const T = {
ROLE_PERMISSION: 'role_permission', ROLE_PERMISSION: 'role_permission',
}; };
export interface IUserPermission { export class AccessStore implements IAccessStore {
project?: string; private logger: Logger;
permission: string;
}
export interface IRole {
id: number;
name: string;
description?: string;
type: string;
project?: string;
}
export interface IUserRole {
roleId: number;
userId: number;
}
export class AccessStore {
private logger: Function;
private timer: Function; private timer: Function;
@ -36,7 +25,7 @@ export class AccessStore {
constructor(db: Knex, eventBus: EventEmitter, getLogger: Function) { constructor(db: Knex, eventBus: EventEmitter, getLogger: Function) {
this.db = db; this.db = db;
this.logger = getLogger('access-store.js'); this.logger = getLogger('access-store.ts');
this.timer = (action: string) => this.timer = (action: string) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'access-store', store: 'access-store',
@ -44,6 +33,37 @@ export class AccessStore {
}); });
} }
async delete(key: number): Promise<void> {
await this.db(T.ROLES).where({ id: key }).del();
}
async deleteAll(): Promise<void> {
await this.db(T.ROLES).del();
}
destroy(): void {}
async exists(key: number): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.ROLES} WHERE id = ?) AS present`,
[key],
);
const { present } = result.rows[0];
return present;
}
async get(key: number): Promise<IRole> {
return this.db
.select(['id', 'name', 'type', 'description'])
.where('id', key)
.first()
.from<IRole>(T.ROLES);
}
async getAll(): Promise<IRole[]> {
return Promise.resolve([]);
}
async getPermissionsForUser(userId: number): Promise<IUserPermission[]> { async getPermissionsForUser(userId: number): Promise<IUserPermission[]> {
const stopTimer = this.timer('getPermissionsForUser'); const stopTimer = this.timer('getPermissionsForUser');
const rows = await this.db const rows = await this.db
@ -110,12 +130,12 @@ export class AccessStore {
.where('ru.user_id', '=', userId); .where('ru.user_id', '=', userId);
} }
async getUserIdsForRole(roleId: number): Promise<IRole[]> { async getUserIdsForRole(roleId: number): Promise<number[]> {
const rows = await this.db const rows = await this.db
.select(['user_id']) .select(['user_id'])
.from<IRole>(T.ROLE_USER) .from<IRole>(T.ROLE_USER)
.where('role_id', roleId); .where('role_id', roleId);
return rows.map(r => r.user_id); return rows.map((r) => r.user_id);
} }
async addUserToRole(userId: number, roleId: number): Promise<void> { async addUserToRole(userId: number, roleId: number): Promise<void> {
@ -155,9 +175,20 @@ export class AccessStore {
description?: string, description?: string,
): Promise<IRole> { ): Promise<IRole> {
const [id] = await this.db(T.ROLES) const [id] = await this.db(T.ROLES)
.insert({ name, description, type, project }) .insert({
name,
description,
type,
project,
})
.returning('id'); .returning('id');
return { id, name, description, type, project }; return {
id,
name,
description,
type,
project,
};
} }
async addPermissionsToRole( async addPermissionsToRole(
@ -165,7 +196,7 @@ export class AccessStore {
permissions: string[], permissions: string[],
projectId?: string, projectId?: string,
): Promise<void> { ): Promise<void> {
const rows = permissions.map(permission => ({ const rows = permissions.map((permission) => ({
role_id, role_id,
project: projectId, project: projectId,
permission, permission,
@ -195,7 +226,7 @@ export class AccessStore {
.leftJoin(`${T.ROLE_USER} AS ru`, 'r.id', 'ru.role_id') .leftJoin(`${T.ROLE_USER} AS ru`, 'r.id', 'ru.role_id')
.where('r.type', '=', 'root'); .where('r.type', '=', 'root');
return rows.map(row => ({ return rows.map((row) => ({
roleId: +row.id, roleId: +row.id,
userId: +row.user_id, userId: +row.user_id,
})); }));

View File

@ -1,105 +0,0 @@
'use strict';
const metricsHelper = require('../util/metrics-helper');
const { DB_TIME } = require('../metric-events');
const NotFoundError = require('../error/notfound-error');
const COLUMNS = [
'id',
'provider',
'enabled',
'description',
'parameters',
'events',
];
const TABLE = 'addons';
class AddonStore {
constructor(db, eventBus, getLogger) {
this.db = db;
this.logger = getLogger('addons-store.js');
this.timer = action =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'addons',
action,
});
}
async getAll(query = {}) {
const stopTimer = this.timer('getAll');
const rows = await this.db
.select(COLUMNS)
.where(query)
.from(TABLE);
stopTimer();
return rows.map(this.rowToAddon);
}
async get(id) {
const stopTimer = this.timer('get');
return this.db
.first(COLUMNS)
.from(TABLE)
.where({ id })
.then(row => {
stopTimer();
if (!row) {
throw new NotFoundError('Could not find addon');
} else {
return this.rowToAddon(row);
}
});
}
async insert(addon) {
const stopTimer = this.timer('insert');
const [id] = await this.db(TABLE).insert(this.addonToRow(addon), 'id');
stopTimer();
return { id, ...addon };
}
async update(id, addon) {
const rows = await this.db(TABLE)
.where({ id })
.update(this.addonToRow(addon));
if (!rows) {
throw new NotFoundError('Could not find addon');
}
return rows;
}
async delete(id) {
const rows = await this.db(TABLE)
.where({ id })
.del();
if (!rows) {
throw new NotFoundError('Could not find addon');
}
return rows;
}
rowToAddon(row) {
return {
id: row.id,
provider: row.provider,
enabled: row.enabled,
description: row.description,
parameters: row.parameters,
events: row.events,
};
}
addonToRow(addon) {
return {
provider: addon.provider,
enabled: addon.enabled,
description: addon.description,
parameters: JSON.stringify(addon.parameters),
events: JSON.stringify(addon.events),
};
}
}
module.exports = AddonStore;

132
src/lib/db/addon-store.ts Normal file
View File

@ -0,0 +1,132 @@
import { Knex } from 'knex';
import EventEmitter from 'events';
import { Logger, LogProvider } from '../logger';
import { IAddon, IAddonDto, IAddonStore } from '../types/stores/addon-store';
import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import NotFoundError from '../error/notfound-error';
const COLUMNS = [
'id',
'provider',
'enabled',
'description',
'parameters',
'events',
];
const TABLE = 'addons';
export default class AddonStore implements IAddonStore {
private db: Knex;
private logger: Logger;
private readonly timer: Function;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('addons-store.ts');
this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'addons',
action,
});
}
destroy(): void {}
async getAll(query = {}): Promise<IAddon[]> {
const stopTimer = this.timer('getAll');
const rows = await this.db.select(COLUMNS).where(query).from(TABLE);
stopTimer();
return rows.map(this.rowToAddon);
}
async get(id: number): Promise<IAddon> {
const stopTimer = this.timer('get');
return this.db
.first(COLUMNS)
.from(TABLE)
.where({ id })
.then((row) => {
stopTimer();
if (!row) {
throw new NotFoundError('Could not find addon');
} else {
return this.rowToAddon(row);
}
});
}
async insert(addon: IAddonDto): Promise<IAddon> {
const stopTimer = this.timer('insert');
// eslint-disable-next-line @typescript-eslint/naming-convention
const rows = await this.db(TABLE).insert(this.addonToRow(addon), [
'id',
'created_at',
]);
stopTimer();
// eslint-disable-next-line @typescript-eslint/naming-convention
const { id, created_at } = rows[0];
return { id, createdAt: created_at, ...addon };
}
async update(id: number, addon: IAddonDto): Promise<IAddon> {
const rows = await this.db(TABLE)
.where({ id })
.update(this.addonToRow(addon));
if (!rows) {
throw new NotFoundError('Could not find addon');
}
return rows[0];
}
async delete(id: number): Promise<void> {
const rows = await this.db(TABLE).where({ id }).del();
if (!rows) {
throw new NotFoundError('Could not find addon');
}
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
async exists(id: number): Promise<boolean> {
const stopTimer = this.timer('exists');
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`,
[id],
);
const { present } = result.rows[0];
stopTimer();
return present;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
rowToAddon(row): IAddon {
return {
id: row.id,
provider: row.provider,
enabled: row.enabled,
description: row.description,
parameters: row.parameters,
events: row.events,
createdAt: row.created_at,
};
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
addonToRow(addon: IAddonDto) {
return {
provider: addon.provider,
enabled: addon.enabled,
description: addon.description,
parameters: JSON.stringify(addon.parameters),
events: JSON.stringify(addon.events),
};
}
}

View File

@ -4,6 +4,12 @@ import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import {
ApiTokenType,
IApiToken,
IApiTokenCreate,
IApiTokenStore,
} from '../types/stores/api-token-store';
const TABLE = 'api_tokens'; const TABLE = 'api_tokens';
@ -17,23 +23,6 @@ interface ITokenTable {
seen_at?: Date; seen_at?: Date;
} }
export enum ApiTokenType {
CLIENT = 'client',
ADMIN = 'admin',
}
export interface IApiTokenCreate {
secret: string;
username: string;
type: ApiTokenType;
expiresAt?: Date;
}
export interface IApiToken extends IApiTokenCreate {
createdAt: Date;
seenAt?: Date;
}
const toRow = (newToken: IApiTokenCreate) => ({ const toRow = (newToken: IApiTokenCreate) => ({
username: newToken.username, username: newToken.username,
secret: newToken.secret, secret: newToken.secret,
@ -49,7 +38,7 @@ const toToken = (row: ITokenTable): IApiToken => ({
createdAt: row.created_at, createdAt: row.created_at,
}); });
export class ApiTokenStore { export class ApiTokenStore implements IApiTokenStore {
private logger: Logger; private logger: Logger;
private timer: Function; private timer: Function;
@ -90,10 +79,24 @@ export class ApiTokenStore {
return { ...newToken, createdAt: row.created_at }; return { ...newToken, createdAt: row.created_at };
} }
destroy(): void {}
async exists(secret: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE secret = ?) AS present`,
[secret],
);
const { present } = result.rows[0];
return present;
}
async get(key: string): Promise<IApiToken> {
const row = await this.db(TABLE).where('secret', key).first();
return toToken(row);
}
async delete(secret: string): Promise<void> { async delete(secret: string): Promise<void> {
return this.db<ITokenTable>(TABLE) return this.db<ITokenTable>(TABLE).where({ secret }).del();
.where({ secret })
.del();
} }
async deleteAll(): Promise<void> { async deleteAll(): Promise<void> {

View File

@ -1,6 +1,12 @@
/* eslint camelcase:off */ import EventEmitter from 'events';
import { Knex } from 'knex';
const NotFoundError = require('../error/notfound-error'); import NotFoundError from '../error/notfound-error';
import {
IClientApplication,
IClientApplicationsStore,
} from '../types/stores/client-applications-store';
import { Logger, LogProvider } from '../logger';
import { IApplicationQuery } from '../types/query';
const COLUMNS = [ const COLUMNS = [
'app_name', 'app_name',
@ -15,7 +21,7 @@ const COLUMNS = [
]; ];
const TABLE = 'client_applications'; const TABLE = 'client_applications';
const mapRow = row => ({ const mapRow: (any) => IClientApplication = (row) => ({
appName: row.app_name, appName: row.app_name,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
@ -25,9 +31,11 @@ const mapRow = row => ({
url: row.url, url: row.url,
color: row.color, color: row.color,
icon: row.icon, icon: row.icon,
lastSeen: row.last_seen,
announced: row.announced,
}); });
const remapRow = input => { const remapRow = (input) => {
const temp = { const temp = {
app_name: input.appName, app_name: input.appName,
updated_at: input.updatedAt || new Date(), updated_at: input.updatedAt || new Date(),
@ -40,7 +48,7 @@ const remapRow = input => {
icon: input.icon, icon: input.icon,
strategies: JSON.stringify(input.strategies), strategies: JSON.stringify(input.strategies),
}; };
Object.keys(temp).forEach(k => { Object.keys(temp).forEach((k) => {
if (temp[k] === undefined) { if (temp[k] === undefined) {
// not using !temp[k] to allow false and null values to get through // not using !temp[k] to allow false and null values to get through
delete temp[k]; delete temp[k];
@ -49,38 +57,38 @@ const remapRow = input => {
return temp; return temp;
}; };
class ClientApplicationsDb { export default class ClientApplicationsStore
constructor(db, eventBus) { implements IClientApplicationsStore
{
private db: Knex;
private logger: Logger;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db; this.db = db;
this.eventBus = eventBus; this.logger = getLogger('client-applications-store.ts');
} }
async upsert(details) { async upsert(details: Partial<IClientApplication>): Promise<void> {
const row = remapRow(details); const row = remapRow(details);
return this.db(TABLE) await this.db(TABLE).insert(row).onConflict('app_name').merge();
.insert(row)
.onConflict('app_name')
.merge();
} }
async bulkUpsert(apps) { async bulkUpsert(apps: Partial<IClientApplication>[]): Promise<void> {
const rows = apps.map(remapRow); const rows = apps.map(remapRow);
return this.db(TABLE) await this.db(TABLE).insert(rows).onConflict('app_name').merge();
.insert(rows)
.onConflict('app_name')
.merge();
} }
async exists({ appName }) { async exists(appName: string): Promise<boolean> {
const result = await this.db.raw( const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE app_name = ?) AS present`, `SELECT EXISTS(SELECT 1 FROM ${TABLE} WHERE app_name = ?) AS present`,
[appName], [appName],
); );
const { present } = result.rows[0]; const { present } = result.rows[0];
return present; return present;
} }
async getAll() { async getAll(): Promise<IClientApplication[]> {
const rows = await this.db const rows = await this.db
.select(COLUMNS) .select(COLUMNS)
.from(TABLE) .from(TABLE)
@ -89,7 +97,7 @@ class ClientApplicationsDb {
return rows.map(mapRow); return rows.map(mapRow);
} }
async getApplication(appName) { async getApplication(appName: string): Promise<IClientApplication> {
const row = await this.db const row = await this.db
.select(COLUMNS) .select(COLUMNS)
.where('app_name', appName) .where('app_name', appName)
@ -103,10 +111,8 @@ class ClientApplicationsDb {
return mapRow(row); return mapRow(row);
} }
async deleteApplication(appName) { async deleteApplication(appName: string): Promise<void> {
return this.db(TABLE) return this.db(TABLE).where('app_name', appName).del();
.where('app_name', appName)
.del();
} }
/** /**
@ -118,23 +124,21 @@ class ClientApplicationsDb {
* ) as foo * ) as foo
* WHERE foo.strategyName = '"other"'; * WHERE foo.strategyName = '"other"';
*/ */
async getAppsForStrategy(strategyName) { async getAppsForStrategy(
query: IApplicationQuery,
): Promise<IClientApplication[]> {
const rows = await this.db.select(COLUMNS).from(TABLE); const rows = await this.db.select(COLUMNS).from(TABLE);
const apps = rows.map(mapRow);
return rows if (query.strategyName) {
.map(mapRow) return apps.filter((app) =>
.filter(apps => app.strategies.includes(query.strategyName),
apps.filter(app => app.strategies.includes(strategyName)),
); );
}
return apps;
} }
async getApplications(filter) { async getUnannounced(): Promise<IClientApplication[]> {
return filter && filter.strategyName
? this.getAppsForStrategy(filter.strategyName)
: this.getAll();
}
async getUnannounced() {
const rows = await this.db(TABLE) const rows = await this.db(TABLE)
.select(COLUMNS) .select(COLUMNS)
.where('announced', false); .where('announced', false);
@ -145,7 +149,7 @@ class ClientApplicationsDb {
* Updates all rows that have announced = false to announced =true and returns the rows altered * Updates all rows that have announced = false to announced =true and returns the rows altered
* @return {[app]} - Apps that hadn't been announced * @return {[app]} - Apps that hadn't been announced
*/ */
async setUnannouncedToAnnounced() { async setUnannouncedToAnnounced(): Promise<IClientApplication[]> {
const rows = await this.db(TABLE) const rows = await this.db(TABLE)
.update({ announced: true }) .update({ announced: true })
.where('announced', false) .where('announced', false)
@ -153,6 +157,28 @@ class ClientApplicationsDb {
.returning(COLUMNS); .returning(COLUMNS);
return rows.map(mapRow); return rows.map(mapRow);
} }
}
module.exports = ClientApplicationsDb; async delete(key: string): Promise<void> {
await this.db(TABLE).where('app_name', key).del();
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
destroy(): void {}
async get(appName: string): Promise<IClientApplication> {
const row = await this.db
.select(COLUMNS)
.where('app_name', appName)
.from(TABLE)
.first();
if (!row) {
throw new NotFoundError(`Could not find appName=${appName}`);
}
return mapRow(row);
}
}

View File

@ -1,6 +1,12 @@
/* eslint camelcase: "off" */ import EventEmitter from 'events';
import { Knex } from 'knex';
'use strict'; import { Logger, LogProvider } from '../logger';
import Timeout = NodeJS.Timeout;
import {
IClientInstance,
IClientInstanceStore,
INewClientInstance,
} from '../types/stores/client-instance-store';
const metricsHelper = require('../util/metrics-helper'); const metricsHelper = require('../util/metrics-helper');
const { DB_TIME } = require('../metric-events'); const { DB_TIME } = require('../metric-events');
@ -17,7 +23,7 @@ const TABLE = 'client_instances';
const ONE_DAY = 24 * 61 * 60 * 1000; const ONE_DAY = 24 * 61 * 60 * 1000;
const mapRow = row => ({ const mapRow = (row) => ({
appName: row.app_name, appName: row.app_name,
instanceId: row.instance_id, instanceId: row.instance_id,
sdkVersion: row.sdk_version, sdkVersion: row.sdk_version,
@ -26,7 +32,7 @@ const mapRow = row => ({
createdAt: row.created_at, createdAt: row.created_at,
}); });
const mapToDb = client => ({ const mapToDb = (client) => ({
app_name: client.appName, app_name: client.appName,
instance_id: client.instanceId, instance_id: client.instanceId,
sdk_version: client.sdkVersion || '', sdk_version: client.sdkVersion || '',
@ -34,12 +40,22 @@ const mapToDb = client => ({
last_seen: client.lastSeen || 'now()', last_seen: client.lastSeen || 'now()',
}); });
class ClientInstanceStore { export default class ClientInstanceStore implements IClientInstanceStore {
constructor(db, eventBus, getLogger) { private db: Knex;
private logger: Logger;
private eventBus: EventEmitter;
private metricTimer: Function;
private timer: Timeout;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db; this.db = db;
this.eventBus = eventBus; this.eventBus = eventBus;
this.logger = getLogger('client-instance-store.js'); this.logger = getLogger('client-instance-store.ts');
this.metricTimer = action => this.metricTimer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'instance', store: 'instance',
action, action,
@ -49,7 +65,7 @@ class ClientInstanceStore {
this.timer = setInterval(clearer, ONE_DAY).unref(); this.timer = setInterval(clearer, ONE_DAY).unref();
} }
async _removeInstancesOlderThanTwoDays() { async _removeInstancesOlderThanTwoDays(): Promise<void> {
const rows = await this.db(TABLE) const rows = await this.db(TABLE)
.whereRaw("created_at < now() - interval '2 days'") .whereRaw("created_at < now() - interval '2 days'")
.del(); .del();
@ -59,15 +75,44 @@ class ClientInstanceStore {
} }
} }
async bulkUpsert(instances) { async bulkUpsert(instances: INewClientInstance[]): Promise<void> {
const rows = instances.map(mapToDb); const rows = instances.map(mapToDb);
return this.db(TABLE) await this.db(TABLE)
.insert(rows) .insert(rows)
.onConflict(['app_name', 'instance_id']) .onConflict(['app_name', 'instance_id'])
.merge(); .merge();
} }
async exists({ appName, instanceId }) { async delete({
appName,
instanceId,
}: Pick<INewClientInstance, 'appName' | 'instanceId'>): Promise<void> {
await this.db(TABLE)
.where({ app_name: appName, instance_id: instanceId })
.del();
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
async get({
appName,
instanceId,
}: Pick<
INewClientInstance,
'appName' | 'instanceId'
>): Promise<IClientInstance> {
const row = await this.db(TABLE)
.where({ app_name: appName, instance_id: instanceId })
.first();
return mapRow(row);
}
async exists({
appName,
instanceId,
}: Pick<INewClientInstance, 'appName' | 'instanceId'>): Promise<boolean> {
const result = await this.db.raw( const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE app_name = ? AND instance_id = ?) AS present`, `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE app_name = ? AND instance_id = ?) AS present`,
[appName, instanceId], [appName, instanceId],
@ -76,20 +121,18 @@ class ClientInstanceStore {
return present; return present;
} }
async insert(details) { async insert(details: INewClientInstance): Promise<void> {
const stopTimer = this.metricTimer('insert'); const stopTimer = this.metricTimer('insert');
const item = await this.db(TABLE) await this.db(TABLE)
.insert(mapToDb(details)) .insert(mapToDb(details))
.onConflict(['app_name', 'instance_id']) .onConflict(['app_name', 'instance_id'])
.merge(); .merge();
stopTimer(); stopTimer();
return item;
} }
async getAll() { async getAll(): Promise<IClientInstance[]> {
const stopTimer = this.metricTimer('getAll'); const stopTimer = this.metricTimer('getAll');
const rows = await this.db const rows = await this.db
@ -104,7 +147,7 @@ class ClientInstanceStore {
return toggles; return toggles;
} }
async getByAppName(appName) { async getByAppName(appName: string): Promise<IClientInstance[]> {
const rows = await this.db const rows = await this.db
.select() .select()
.from(TABLE) .from(TABLE)
@ -114,25 +157,21 @@ class ClientInstanceStore {
return rows.map(mapRow); return rows.map(mapRow);
} }
async getApplications() { async getDistinctApplications(): Promise<string[]> {
const rows = await this.db const rows = await this.db
.distinct('app_name') .distinct('app_name')
.select(['app_name']) .select(['app_name'])
.from(TABLE) .from(TABLE)
.orderBy('app_name', 'desc'); .orderBy('app_name', 'desc');
return rows.map(mapRow); return rows.map((r) => r.app_name);
} }
async deleteForApplication(appName) { async deleteForApplication(appName: string): Promise<void> {
return this.db(TABLE) return this.db(TABLE).where('app_name', appName).del();
.where('app_name', appName)
.del();
} }
destroy() { destroy(): void {
clearInterval(this.timer); clearInterval(this.timer);
} }
} }
module.exports = ClientInstanceStore;

View File

@ -1,23 +1,18 @@
import { Knex } from 'knex'; import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import { IClientMetric } from '../types/stores/client-metrics-db';
const METRICS_COLUMNS = ['id', 'created_at', 'metrics']; const METRICS_COLUMNS = ['id', 'created_at', 'metrics'];
const TABLE = 'client_metrics'; const TABLE = 'client_metrics';
const ONE_MINUTE = 60 * 1000; const ONE_MINUTE = 60 * 1000;
const mapRow = row => ({ const mapRow = (row) => ({
id: row.id, id: row.id,
createdAt: row.created_at, createdAt: row.created_at,
metrics: row.metrics, metrics: row.metrics,
}); });
export interface IClientMetric {
id: number;
createdAt: Date;
metrics: any;
}
export class ClientMetricsDb { export class ClientMetricsDb {
private readonly logger: Logger; private readonly logger: Logger;
@ -45,6 +40,14 @@ export class ClientMetricsDb {
} }
} }
async delete(id: number): Promise<void> {
await this.db(TABLE).where({ id }).del();
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
// Insert new client metrics // Insert new client metrics
async insert(metrics: IClientMetric): Promise<void> { async insert(metrics: IClientMetric): Promise<void> {
return this.db(TABLE).insert({ metrics }); return this.db(TABLE).insert({ metrics });
@ -66,6 +69,24 @@ export class ClientMetricsDb {
return []; return [];
} }
async get(id: number): Promise<IClientMetric> {
const result = await this.db
.select(METRICS_COLUMNS)
.from(TABLE)
.where({ id })
.first();
return mapRow(result);
}
async exists(id: number): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`,
[id],
);
const { present } = result.rows[0];
return present;
}
// Used to poll for new metrics // Used to poll for new metrics
async getNewMetrics(lastKnownId: number): Promise<IClientMetric[]> { async getNewMetrics(lastKnownId: number): Promise<IClientMetric[]> {
try { try {

View File

@ -23,7 +23,7 @@ function getMockDb() {
}; };
} }
test('should call database on startup', done => { test('should call database on startup', (done) => {
jest.useFakeTimers('modern'); jest.useFakeTimers('modern');
const mock = getMockDb(); const mock = getMockDb();
const ee = new EventEmitter(); const ee = new EventEmitter();
@ -33,7 +33,7 @@ test('should call database on startup', done => {
expect.assertions(2); expect.assertions(2);
store.on('metrics', metrics => { store.on('metrics', (metrics) => {
expect(store.highestIdSeen).toBe(1); expect(store.highestIdSeen).toBe(1);
expect(metrics.appName).toBe('test'); expect(metrics.appName).toBe('test');
store.destroy(); store.destroy();
@ -42,7 +42,7 @@ test('should call database on startup', done => {
}); });
}); });
test('should start poller even if initial database fetch fails', done => { test('should start poller even if initial database fetch fails', (done) => {
jest.useFakeTimers('modern'); jest.useFakeTimers('modern');
getLogger.setMuteError(true); getLogger.setMuteError(true);
const mock = getMockDb(); const mock = getMockDb();
@ -52,7 +52,7 @@ test('should start poller even if initial database fetch fails', done => {
jest.runAllTicks(); jest.runAllTicks();
const metrics = []; const metrics = [];
store.on('metrics', m => metrics.push(m)); store.on('metrics', (m) => metrics.push(m));
store.on('ready', () => { store.on('ready', () => {
jest.useFakeTimers('modern'); jest.useFakeTimers('modern');
@ -69,7 +69,7 @@ test('should start poller even if initial database fetch fails', done => {
getLogger.setMuteError(false); getLogger.setMuteError(false);
}); });
test('should poll for updates', done => { test('should poll for updates', (done) => {
jest.useFakeTimers('modern'); jest.useFakeTimers('modern');
const mock = getMockDb(); const mock = getMockDb();
const ee = new EventEmitter(); const ee = new EventEmitter();
@ -77,7 +77,7 @@ test('should poll for updates', done => {
jest.runAllTicks(); jest.runAllTicks();
const metrics = []; const metrics = [];
store.on('metrics', m => metrics.push(m)); store.on('metrics', (m) => metrics.push(m));
expect(metrics).toHaveLength(0); expect(metrics).toHaveLength(0);

View File

@ -1,14 +1,17 @@
'use strict';
import EventEmitter from 'events'; import EventEmitter from 'events';
import { ClientMetricsDb, IClientMetric } from './client-metrics-db';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import metricsHelper from '../util/metrics-helper'; import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { ClientMetricsDb } from './client-metrics-db';
import { IClientMetric } from '../types/stores/client-metrics-db';
import { IClientMetricsStore } from '../types/stores/client-metrics-store';
const TEN_SECONDS = 10 * 1000; const TEN_SECONDS = 10 * 1000;
export class ClientMetricsStore extends EventEmitter { export class ClientMetricsStore
extends EventEmitter
implements IClientMetricsStore
{
private logger: Logger; private logger: Logger;
highestIdSeen = 0; highestIdSeen = 0;
@ -24,11 +27,11 @@ export class ClientMetricsStore extends EventEmitter {
pollInterval = TEN_SECONDS, pollInterval = TEN_SECONDS,
) { ) {
super(); super();
this.logger = getLogger('client-metrics-store.js'); this.logger = getLogger('client-metrics-store.ts.js');
this.metricsDb = metricsDb; this.metricsDb = metricsDb;
this.highestIdSeen = 0; this.highestIdSeen = 0;
this.startTimer = action => this.startTimer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'metrics', store: 'metrics',
action, action,
@ -58,13 +61,13 @@ export class ClientMetricsStore extends EventEmitter {
_fetchNewAndEmit(): void { _fetchNewAndEmit(): void {
this.metricsDb this.metricsDb
.getNewMetrics(this.highestIdSeen) .getNewMetrics(this.highestIdSeen)
.then(metrics => this._emitMetrics(metrics)); .then((metrics) => this._emitMetrics(metrics));
} }
_emitMetrics(metrics: IClientMetric[]): void { _emitMetrics(metrics: IClientMetric[]): void {
if (metrics && metrics.length > 0) { if (metrics && metrics.length > 0) {
this.highestIdSeen = metrics[metrics.length - 1].id; this.highestIdSeen = metrics[metrics.length - 1].id;
metrics.forEach(m => this.emit('metrics', m.metrics)); metrics.forEach((m) => this.emit('metrics', m.metrics));
} }
} }
@ -81,4 +84,24 @@ export class ClientMetricsStore extends EventEmitter {
clearInterval(this.timer); clearInterval(this.timer);
this.metricsDb.destroy(); this.metricsDb.destroy();
} }
async delete(key: number): Promise<void> {
await this.metricsDb.delete(key);
}
async deleteAll(): Promise<void> {
await this.metricsDb.deleteAll();
}
async exists(key: number): Promise<boolean> {
return this.metricsDb.exists(key);
}
async get(key: number): Promise<IClientMetric> {
return this.metricsDb.get(key);
}
async getAll(): Promise<IClientMetric[]> {
return this.metricsDb.getMetricsLastHour();
}
} }

View File

@ -1,5 +1,9 @@
import { Knex } from 'knex'; import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import {
IContextField,
IContextFieldStore,
} from '../types/stores/context-field-store';
const COLUMNS = [ const COLUMNS = [
'name', 'name',
@ -11,7 +15,7 @@ const COLUMNS = [
]; ];
const TABLE = 'context_fields'; const TABLE = 'context_fields';
const mapRow: (IContextRow) => IContextField = row => ({ const mapRow: (IContextRow) => IContextField = (row) => ({
name: row.name, name: row.name,
description: row.description, description: row.description,
stickiness: row.stickiness, stickiness: row.stickiness,
@ -20,7 +24,7 @@ const mapRow: (IContextRow) => IContextField = row => ({
createdAt: row.created_at, createdAt: row.created_at,
}); });
export interface ICreateContextField { interface ICreateContextField {
name: string; name: string;
description: string; description: string;
stickiness: boolean; stickiness: boolean;
@ -29,23 +33,14 @@ export interface ICreateContextField {
updated_at: Date; updated_at: Date;
} }
export interface IContextField { class ContextFieldStore implements IContextFieldStore {
name: string;
description: string;
stickiness: boolean;
sortOrder: number;
legalValues?: string[];
createdAt: Date;
}
class ContextFieldStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) { constructor(db: Knex, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('context-field-store.js'); this.logger = getLogger('context-field-store.ts');
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@ -69,30 +64,48 @@ class ContextFieldStore {
return rows.map(mapRow); return rows.map(mapRow);
} }
async get(name: string): Promise<IContextField> { async get(key: string): Promise<IContextField> {
return this.db return this.db
.first(COLUMNS) .first(COLUMNS)
.from(TABLE) .from(TABLE)
.where({ name }) .where({ name: key })
.then(mapRow); .then(mapRow);
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async deleteAll(): Promise<void> {
async create(contextField): Promise<void> { await this.db(TABLE).del();
return this.db(TABLE).insert(this.fieldToRow(contextField)); }
destroy(): void {}
async exists(key: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`,
[key],
);
const { present } = result.rows[0];
return present;
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async update(data): Promise<void> { async create(contextField): Promise<IContextField> {
return this.db(TABLE) const row = await this.db(TABLE)
.insert(this.fieldToRow(contextField))
.returning('*');
return mapRow(row);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async update(data): Promise<IContextField> {
const row = this.db(TABLE)
.where({ name: data.name }) .where({ name: data.name })
.update(this.fieldToRow(data)); .update(this.fieldToRow(data))
.returning('*');
return mapRow(row);
} }
async delete(name: string): Promise<void> { async delete(name: string): Promise<void> {
return this.db(TABLE) return this.db(TABLE).where({ name }).del();
.where({ name })
.del();
} }
} }
export default ContextFieldStore; export default ContextFieldStore;

View File

@ -14,9 +14,9 @@ export function createDb({
searchPath: db.schema, searchPath: db.schema,
asyncStackTraces: true, asyncStackTraces: true,
log: { log: {
debug: msg => logger.debug(msg), debug: (msg) => logger.debug(msg),
warn: msg => logger.warn(msg), warn: (msg) => logger.warn(msg),
error: msg => logger.error(msg), error: (msg) => logger.error(msg),
}, },
}); });
} }

View File

@ -5,6 +5,7 @@ import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { IEnvironment } from '../types/model'; import { IEnvironment } from '../types/model';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { IEnvironmentStore } from '../types/stores/environment-store';
interface IEnvironmentsTable { interface IEnvironmentsTable {
name: string; name: string;
@ -34,7 +35,7 @@ function mapInput(input: IEnvironment): IEnvironmentsTable {
const TABLE = 'environments'; const TABLE = 'environments';
export default class EnvironmentStore { export default class EnvironmentStore implements IEnvironmentStore {
private logger: Logger; private logger: Logger;
private db: Knex; private db: Knex;
@ -44,19 +45,33 @@ export default class EnvironmentStore {
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('db/environment-store.ts'); this.logger = getLogger('db/environment-store.ts');
this.timer = action => this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'environment', store: 'environment',
action, action,
}); });
} }
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
async get(key: string): Promise<IEnvironment> {
const row = await this.db<IEnvironmentsTable>(TABLE)
.where({ name: key })
.first();
if (row) {
return mapRow(row);
}
throw new NotFoundError(`Could not find environment with name: ${key}`);
}
async getAll(): Promise<IEnvironment[]> { async getAll(): Promise<IEnvironment[]> {
const rows = await this.db<IEnvironmentsTable>(TABLE).select('*'); const rows = await this.db<IEnvironmentsTable>(TABLE).select('*');
return rows.map(mapRow); return rows.map(mapRow);
} }
async exists(name: string): Promise<Boolean> { async exists(name: string): Promise<boolean> {
const result = await this.db.raw( const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`, `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`,
[name], [name],
@ -104,7 +119,7 @@ export default class EnvironmentStore {
.where({ .where({
project: projectId, project: projectId,
}); });
const rows: IFeatureEnvironmentRow[] = featuresToEnable.map(f => ({ const rows: IFeatureEnvironmentRow[] = featuresToEnable.map((f) => ({
environment, environment,
feature_name: f.name, feature_name: f.name,
enabled: false, enabled: false,
@ -118,9 +133,7 @@ export default class EnvironmentStore {
} }
async delete(name: string): Promise<void> { async delete(name: string): Promise<void> {
await this.db(TABLE) await this.db(TABLE).where({ name }).del();
.where({ name })
.del();
} }
async disconnectProjectFromEnv( async disconnectProjectFromEnv(
@ -140,7 +153,7 @@ export default class EnvironmentStore {
.select('environment_name') .select('environment_name')
.where({ project_id }); .where({ project_id });
await Promise.all( await Promise.all(
environmentsToEnable.map(async env => { environmentsToEnable.map(async (env) => {
await this.db('feature_environments') await this.db('feature_environments')
.insert({ .insert({
environment: env.environment_name, environment: env.environment_name,
@ -152,4 +165,6 @@ export default class EnvironmentStore {
}), }),
); );
} }
destroy(): void {}
} }

View File

@ -16,7 +16,7 @@ test('Trying to get events by name if db fails should yield empty list', async (
client: 'pg', client: 'pg',
}); });
const store = new EventStore(db, getLogger); const store = new EventStore(db, getLogger);
const events = await store.getEventsFilterByName('application-created'); const events = await store.getEventsFilterByType('application-created');
expect(events).toBeTruthy(); expect(events).toBeTruthy();
expect(events.length).toBe(0); expect(events.length).toBe(0);
}); });

View File

@ -2,7 +2,8 @@ import { EventEmitter } from 'events';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { DROP_FEATURES } from '../types/events'; import { DROP_FEATURES } from '../types/events';
import { LogProvider, Logger } from '../logger'; import { LogProvider, Logger } from '../logger';
import { ITag } from '../types/model'; import { IEventStore } from '../types/stores/event-store';
import { ICreateEvent, IEvent } from '../types/model';
const EVENT_COLUMNS = [ const EVENT_COLUMNS = [
'id', 'id',
@ -13,7 +14,7 @@ const EVENT_COLUMNS = [
'tags', 'tags',
]; ];
interface IEventTable { export interface IEventTable {
id: number; id: number;
type: string; type: string;
created_by: string; created_by: string;
@ -22,21 +23,9 @@ interface IEventTable {
tags: []; tags: [];
} }
export interface ICreateEvent {
type: string;
createdBy: string;
data?: any;
tags?: ITag[];
}
export interface IEvent extends ICreateEvent {
id: number;
createdAt: Date;
}
const TABLE = 'events'; const TABLE = 'events';
class EventStore extends EventEmitter { class EventStore extends EventEmitter implements IEventStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
@ -44,7 +33,7 @@ class EventStore extends EventEmitter {
constructor(db: Knex, getLogger: LogProvider) { constructor(db: Knex, getLogger: LogProvider) {
super(); super();
this.db = db; this.db = db;
this.logger = getLogger('lib/db/event-store.js'); this.logger = getLogger('lib/db/event-store.ts');
} }
async store(event: ICreateEvent): Promise<void> { async store(event: ICreateEvent): Promise<void> {
@ -66,13 +55,41 @@ class EventStore extends EventEmitter {
.returning(EVENT_COLUMNS); .returning(EVENT_COLUMNS);
const savedEvents = savedRows.map(this.rowToEvent); const savedEvents = savedRows.map(this.rowToEvent);
process.nextTick(() => process.nextTick(() =>
savedEvents.forEach(e => this.emit(e.type, e)), savedEvents.forEach((e) => this.emit(e.type, e)),
); );
} catch (e) { } catch (e) {
this.logger.warn('Failed to store events'); this.logger.warn('Failed to store events');
} }
} }
async delete(key: number): Promise<void> {
await this.db(TABLE).where({ id: key }).del();
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
destroy(): void {}
async exists(key: number): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`,
[key],
);
const { present } = result.rows[0];
return present;
}
async get(key: number): Promise<IEvent> {
const row = await this.db(TABLE).where({ id: key }).first();
return this.rowToEvent(row);
}
async getAll(): Promise<IEvent[]> {
return this.getEvents();
}
async getEvents(): Promise<IEvent[]> { async getEvents(): Promise<IEvent[]> {
try { try {
const rows = await this.db const rows = await this.db
@ -87,7 +104,7 @@ class EventStore extends EventEmitter {
} }
} }
async getEventsFilterByName(name: string): Promise<IEvent[]> { async getEventsFilterByType(name: string): Promise<IEvent[]> {
try { try {
const rows = await this.db const rows = await this.db
.select(EVENT_COLUMNS) .select(EVENT_COLUMNS)
@ -116,7 +133,7 @@ class EventStore extends EventEmitter {
createdBy: row.created_by, createdBy: row.created_by,
createdAt: row.created_at, createdAt: row.created_at,
data: row.data, data: row.data,
tags: row.tags, tags: row.tags || [],
}; };
} }

View File

@ -0,0 +1,195 @@
import EventEmitter from 'events';
import { Knex } from 'knex';
import {
FeatureEnvironmentKey,
IFeatureEnvironmentStore,
} from '../types/stores/feature-environment-store';
import { Logger, LogProvider } from '../logger';
import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import { IFeatureEnvironment } from '../types/model';
import NotFoundError from '../error/notfound-error';
const T = { featureEnvs: 'feature_environments' };
export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
private db: Knex;
private logger: Logger;
private readonly timer: Function;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('feature-environment-store.ts');
this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-environments',
action,
});
}
async delete({
featureName,
environment,
}: FeatureEnvironmentKey): Promise<void> {
await this.db(T.featureEnvs)
.where('feature_name', featureName)
.andWhere('environment', environment)
.del();
}
async deleteAll(): Promise<void> {
await this.db(T.featureEnvs).del();
}
destroy(): void {}
async exists({
featureName,
environment,
}: FeatureEnvironmentKey): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.featureEnvs} WHERE feature_name = ? AND environment = ?) AS present`,
[featureName, environment],
);
const { present } = result.rows[0];
return present;
}
async get({
featureName,
environment,
}: FeatureEnvironmentKey): Promise<IFeatureEnvironment> {
const md = await this.db(T.featureEnvs)
.where('feature_name', featureName)
.andWhere('environment', environment)
.first();
if (md) {
return {
enabled: md.enabled,
featureName,
environment,
};
}
throw new NotFoundError(
`Could not find ${featureName} in ${environment}`,
);
}
async getAll(): Promise<IFeatureEnvironment[]> {
const rows = await this.db(T.featureEnvs);
return rows.map((r) => ({
enabled: r.enabled,
featureName: r.feature_name,
environment: r.environment,
}));
}
async connectEnvironmentAndFeature(
feature_name: string,
environment: string,
enabled: boolean = false,
): Promise<void> {
await this.db('feature_environments')
.insert({ feature_name, environment, enabled })
.onConflict(['environment', 'feature_name'])
.merge('enabled');
}
async disconnectEnvironmentFromProject(
environment: string,
project: string,
): Promise<void> {
const featureSelector = this.db('features')
.where({ project })
.select('name');
await this.db(T.featureEnvs)
.where({ environment })
.andWhere('feature_name', 'IN', featureSelector)
.del();
await this.db('feature_strategies').where({
environment,
project_name: project,
});
}
async enableEnvironmentForFeature(
feature_name: string,
environment: string,
): Promise<void> {
await this.db(T.featureEnvs)
.update({ enabled: true })
.where({ feature_name, environment });
}
async featureHasEnvironment(
environment: string,
featureName: string,
): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.featureEnvs} WHERE feature_name = ? AND environment = ?) AS present`,
[featureName, environment],
);
const { present } = result.rows[0];
return present;
}
async getAllFeatureEnvironments(): Promise<IFeatureEnvironment[]> {
const rows = await this.db(T.featureEnvs);
return rows.map((r) => ({
environment: r.environment,
featureName: r.feature_name,
enabled: r.enabled,
}));
}
async getEnvironmentMetaData(
environment: string,
featureName: string,
): Promise<IFeatureEnvironment> {
const md = await this.db(T.featureEnvs)
.where('feature_name', featureName)
.andWhere('environment', environment)
.first();
if (md) {
return {
enabled: md.enabled,
featureName,
environment,
};
}
throw new NotFoundError(
`Could not find ${featureName} in ${environment}`,
);
}
async isEnvironmentEnabled(
featureName: string,
environment: string,
): Promise<boolean> {
const row = await this.db(T.featureEnvs)
.select('enabled')
.where({ feature_name: featureName, environment })
.first();
return row.enabled;
}
async removeEnvironmentForFeature(
feature_name: string,
environment: string,
): Promise<void> {
await this.db(T.featureEnvs).where({ feature_name, environment }).del();
}
async toggleEnvironmentEnabledStatus(
environment: string,
featureName: string,
enabled: boolean,
): Promise<boolean> {
await this.db(T.featureEnvs)
.update({ enabled })
.where({ environment, feature_name: featureName });
return enabled;
}
}

View File

@ -4,18 +4,16 @@ import * as uuid from 'uuid';
import metricsHelper from '../util/metrics-helper'; import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error';
import { import {
FeatureToggleWithEnvironment,
IConstraint, IConstraint,
IEnvironmentOverview, IFeatureStrategy,
IFeatureOverview,
IFeatureToggleClient, IFeatureToggleClient,
IFeatureToggleQuery, IFeatureToggleQuery,
IStrategyConfig, IStrategyConfig,
IVariant,
FeatureToggleWithEnvironment,
IFeatureEnvironment,
} from '../types/model'; } from '../types/model';
import NotFoundError from '../error/notfound-error'; import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
const COLUMNS = [ const COLUMNS = [
'id', 'id',
@ -52,26 +50,6 @@ interface IFeatureStrategiesTable {
created_at?: Date; created_at?: Date;
} }
export interface IFeatureStrategy {
id: string;
featureName: string;
projectName: string;
environment: string;
strategyName: string;
parameters: object;
constraints: IConstraint[];
createdAt?: Date;
}
export interface FeatureConfigurationClient {
name: string;
type: string;
enabled: boolean;
stale: boolean;
strategies: IStrategyConfig[];
variants: IVariant[];
}
function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy { function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy {
return { return {
id: row.id, id: row.id,
@ -80,7 +58,7 @@ function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy {
environment: row.environment, environment: row.environment,
strategyName: row.strategy_name, strategyName: row.strategy_name,
parameters: row.parameters, parameters: row.parameters,
constraints: ((row.constraints as unknown) as IConstraint[]) || [], constraints: (row.constraints as unknown as IConstraint[]) || [],
createdAt: row.created_at, createdAt: row.created_at,
}; };
} }
@ -118,7 +96,7 @@ function mapStrategyUpdate(
return update; return update;
} }
class FeatureStrategiesStore { class FeatureStrategiesStore implements IFeatureStrategiesStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
@ -128,13 +106,39 @@ class FeatureStrategiesStore {
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('feature-toggle-store.ts'); this.logger = getLogger('feature-toggle-store.ts');
this.timer = action => this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-toggle-strategies', store: 'feature-toggle-strategies',
action, action,
}); });
} }
async delete(key: string): Promise<void> {
await this.db(T.featureStrategies).where({ id: key }).del();
}
async deleteAll(): Promise<void> {
await this.db(T.featureStrategies).delete();
}
destroy(): void {}
async exists(key: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.featureStrategies} WHERE id = ?) AS present`,
[key],
);
const { present } = result.rows[0];
return present;
}
async get(key: string): Promise<IFeatureStrategy> {
const row = await this.db(T.featureStrategies)
.where({ id: key })
.first();
return mapRow(row);
}
async createStrategyConfig( async createStrategyConfig(
strategyConfig: Omit<IFeatureStrategy, 'id' | 'createdAt'>, strategyConfig: Omit<IFeatureStrategy, 'id' | 'createdAt'>,
): Promise<IFeatureStrategy> { ): Promise<IFeatureStrategy> {
@ -163,10 +167,6 @@ class FeatureStrategiesStore {
return rows.map(mapRow); return rows.map(mapRow);
} }
async deleteFeatureStrategies(): Promise<void> {
await this.db(T.featureStrategies).delete();
}
async getStrategiesForEnvironment( async getStrategiesForEnvironment(
environment: string, environment: string,
): Promise<IFeatureStrategy[]> { ): Promise<IFeatureStrategy[]> {
@ -394,61 +394,13 @@ class FeatureStrategiesStore {
} }
async getStrategyById(id: string): Promise<IFeatureStrategy> { async getStrategyById(id: string): Promise<IFeatureStrategy> {
const strat = await this.db(T.featureStrategies) const strat = await this.db(T.featureStrategies).where({ id }).first();
.where({ id })
.first();
if (strat) { if (strat) {
return mapRow(strat); return mapRow(strat);
} }
throw new NotFoundError(`Could not find strategy with id: ${id}`); throw new NotFoundError(`Could not find strategy with id: ${id}`);
} }
async connectEnvironmentAndFeature(
feature_name: string,
environment: string,
enabled: boolean = false,
): Promise<void> {
await this.db('feature_environments')
.insert({ feature_name, environment, enabled })
.onConflict(['environment', 'feature_name'])
.merge('enabled');
}
async enableEnvironmentForFeature(
feature_name: string,
environment: string,
): Promise<void> {
await this.db(T.featureEnvs)
.update({ enabled: true })
.where({ feature_name, environment });
}
async removeEnvironmentForFeature(
feature_name: string,
environment: string,
): Promise<void> {
await this.db(T.featureEnvs)
.where({ feature_name, environment })
.del();
}
async disconnectEnvironmentFromProject(
environment: string,
project: string,
): Promise<void> {
const featureSelector = this.db('features')
.where({ project })
.select('name');
await this.db(T.featureEnvs)
.where({ environment })
.andWhere('feature_name', 'IN', featureSelector)
.del();
await this.db('feature_strategies').where({
environment,
project_name: project,
});
}
async updateStrategy( async updateStrategy(
id: string, id: string,
updates: Partial<IFeatureStrategy>, updates: Partial<IFeatureStrategy>,
@ -477,26 +429,6 @@ class FeatureStrategiesStore {
return strategy; return strategy;
} }
async getEnvironmentMetaData(
environment: string,
featureName: string,
): Promise<IFeatureEnvironment> {
const md = await this.db(T.featureEnvs)
.where('feature_name', featureName)
.andWhere('environment', environment)
.first();
if (md) {
return {
enabled: md.enabled,
featureName,
environment,
};
}
throw new NotFoundError(
`Could not find ${featureName} in ${environment}`,
);
}
async getStrategiesAndMetadataForEnvironment( async getStrategiesAndMetadataForEnvironment(
environment: string, environment: string,
featureName: string, featureName: string,
@ -530,58 +462,6 @@ class FeatureStrategiesStore {
.where({ project_name: projectId, environment }) .where({ project_name: projectId, environment })
.del(); .del();
} }
async isEnvironmentEnabled(
featureName: string,
environment: string,
): Promise<boolean> {
const row = await this.db(T.featureEnvs)
.select('enabled')
.where({ feature_name: featureName, environment })
.first();
return row.enabled;
}
async toggleEnvironmentEnabledStatus(
environment: string,
featureName: string,
enabled: boolean,
): Promise<boolean> {
await this.db(T.featureEnvs)
.update({ enabled })
.where({ environment, feature_name: featureName });
return enabled;
}
async getAllFeatureEnvironments(): Promise<IFeatureEnvironment[]> {
const rows = await this.db(T.featureEnvs);
return rows.map(r => ({
environment: r.environment,
featureName: r.feature_name,
enabled: r.enabled,
}));
}
async featureHasEnvironment(
environment: string,
featureName: string,
): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.featureEnvs} WHERE feature_name = ? AND environment = ?) AS present`,
[featureName, environment],
);
const { present } = result.rows[0];
return present;
}
async hasStrategy(id: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.featureStrategies} WHERE id = ?) AS present`,
[id],
);
const { present } = result.rows[0];
return present;
}
} }
module.exports = FeatureStrategiesStore; module.exports = FeatureStrategiesStore;

View File

@ -6,6 +6,11 @@ import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { UNIQUE_CONSTRAINT_VIOLATION } from '../error/db-error'; import { UNIQUE_CONSTRAINT_VIOLATION } from '../error/db-error';
import FeatureHasTagError from '../error/feature-has-tag-error'; import FeatureHasTagError from '../error/feature-has-tag-error';
import {
IFeatureAndTag,
IFeatureTag,
IFeatureTagStore,
} from '../types/stores/feature-tag-store';
const COLUMNS = ['feature_name', 'tag_type', 'tag_value']; const COLUMNS = ['feature_name', 'tag_type', 'tag_value'];
const TABLE = 'feature_tag'; const TABLE = 'feature_tag';
@ -16,18 +21,7 @@ interface FeatureTagTable {
tag_value: string; tag_value: string;
} }
export interface IFeatureTag { class FeatureTagStore implements IFeatureTagStore {
featureName: string;
tagType: string;
tagValue: string;
}
export interface IFeatureAndTag {
featureName: string;
tag: ITag;
}
class FeatureTagStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
@ -36,14 +30,71 @@ class FeatureTagStore {
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('feature-tag-store.js'); this.logger = getLogger('feature-tag-store.ts');
this.timer = action => this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-toggle', store: 'feature-toggle',
action, action,
}); });
} }
async delete({
featureName,
tagType,
tagValue,
}: IFeatureTag): Promise<void> {
await this.db(TABLE)
.where({
feature_name: featureName,
tag_type: tagType,
tag_value: tagValue,
})
.del();
}
destroy(): void {}
async exists({
featureName,
tagType,
tagValue,
}: IFeatureTag): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE feature_name = ? AND tag_type = ? AND tag_value = ?) AS present`,
[featureName, tagType, tagValue],
);
const { present } = result.rows[0];
return present;
}
async get({
featureName,
tagType,
tagValue,
}: IFeatureTag): Promise<IFeatureTag> {
const row = await this.db(TABLE)
.where({
feature_name: featureName,
tag_type: tagType,
tag_value: tagValue,
})
.first();
return {
featureName: row.feature_name,
tagType: row.tag_type,
tagValue: row.tag_value,
};
}
async getAll(): Promise<IFeatureTag[]> {
const rows = await this.db(TABLE).select(COLUMNS);
return rows.map((row) => ({
featureName: row.feature_name,
tagType: row.tag_type,
tagValue: row.tag_value,
}));
}
async getAllTagsForFeature(featureName: string): Promise<ITag[]> { async getAllTagsForFeature(featureName: string): Promise<ITag[]> {
const stopTimer = this.timer('getAllForFeature'); const stopTimer = this.timer('getAllForFeature');
const rows = await this.db const rows = await this.db
@ -58,7 +109,7 @@ class FeatureTagStore {
const stopTimer = this.timer('tagFeature'); const stopTimer = this.timer('tagFeature');
await this.db<FeatureTagTable>(TABLE) await this.db<FeatureTagTable>(TABLE)
.insert(this.featureAndTagToRow(featureName, tag)) .insert(this.featureAndTagToRow(featureName, tag))
.catch(err => { .catch((err) => {
if (err.code === UNIQUE_CONSTRAINT_VIOLATION) { if (err.code === UNIQUE_CONSTRAINT_VIOLATION) {
throw new FeatureHasTagError( throw new FeatureHasTagError(
`${featureName} already had the tag: [${tag.type}:${tag.value}]`, `${featureName} already had the tag: [${tag.type}:${tag.value}]`,
@ -79,19 +130,17 @@ class FeatureTagStore {
.select(COLUMNS) .select(COLUMNS)
.whereIn( .whereIn(
'feature_name', 'feature_name',
this.db('features') this.db('features').where({ archived: false }).select(['name']),
.where({ archived: false })
.select(['name']),
); );
return rows.map(row => ({ return rows.map((row) => ({
featureName: row.feature_name, featureName: row.feature_name,
tagType: row.tag_type, tagType: row.tag_type,
tagValue: row.tag_value, tagValue: row.tag_value,
})); }));
} }
async dropFeatureTags(): Promise<void> { async deleteAll(): Promise<void> {
const stopTimer = this.timer('dropFeatureTags'); const stopTimer = this.timer('deleteAll');
await this.db(TABLE).del(); await this.db(TABLE).del();
stopTimer(); stopTimer();
} }

View File

@ -5,6 +5,7 @@ import { DB_TIME } from '../metric-events';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import { FeatureToggleDTO, FeatureToggle, IVariant } from '../types/model'; import { FeatureToggleDTO, FeatureToggle, IVariant } from '../types/model';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
const FEATURE_COLUMNS = [ const FEATURE_COLUMNS = [
'name', 'name',
@ -30,7 +31,7 @@ export interface FeaturesTable {
const TABLE = 'features'; const TABLE = 'features';
export default class FeatureToggleStore { export default class FeatureToggleStore implements IFeatureToggleStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
@ -40,7 +41,7 @@ export default class FeatureToggleStore {
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('feature-toggle-store.ts'); this.logger = getLogger('feature-toggle-store.ts');
this.timer = action => this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-toggle', store: 'feature-toggle',
action, action,
@ -58,7 +59,7 @@ export default class FeatureToggleStore {
.count('*') .count('*')
.from(TABLE) .from(TABLE)
.where(query) .where(query)
.then(res => Number(res[0].count)); .then((res) => Number(res[0].count));
} }
async getFeatureMetadata(name: string): Promise<FeatureToggle> { async getFeatureMetadata(name: string): Promise<FeatureToggle> {
@ -69,7 +70,29 @@ export default class FeatureToggleStore {
.then(this.rowToFeature); .then(this.rowToFeature);
} }
async getFeatures(archived: boolean = false): Promise<FeatureToggle[]> { async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
destroy(): void {}
async get(name: string): Promise<FeatureToggle> {
return this.db
.first(FEATURE_COLUMNS)
.from(TABLE)
.where({ name, archived: 0 })
.then(this.rowToFeature);
}
async getAll(): Promise<FeatureToggle[]> {
const rows = await this.db
.select(FEATURE_COLUMNS)
.from(TABLE)
.where({ archived: false });
return rows.map(this.rowToFeature);
}
async getFeatures(archived: boolean): Promise<FeatureToggle[]> {
const rows = await this.db const rows = await this.db
.select(FEATURE_COLUMNS) .select(FEATURE_COLUMNS)
.from(TABLE) .from(TABLE)
@ -87,8 +110,8 @@ export default class FeatureToggleStore {
.first(['project']) .first(['project'])
.from(TABLE) .from(TABLE)
.where({ name }) .where({ name })
.then(r => (r ? r.project : undefined)) .then((r) => (r ? r.project : undefined))
.catch(e => { .catch((e) => {
this.logger.error(e); this.logger.error(e);
return undefined; return undefined;
}); });
@ -103,7 +126,7 @@ export default class FeatureToggleStore {
.first('name', 'archived') .first('name', 'archived')
.from(TABLE) .from(TABLE)
.where({ name }) .where({ name })
.then(row => { .then((row) => {
if (!row) { if (!row) {
throw new NotFoundError('No feature toggle found'); throw new NotFoundError('No feature toggle found');
} }
@ -116,7 +139,7 @@ export default class FeatureToggleStore {
async exists(name: string): Promise<boolean> { async exists(name: string): Promise<boolean> {
const result = await this.db.raw( const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present`, 'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present',
[name], [name],
); );
const { present } = result.rows[0]; const { present } = result.rows[0];
@ -132,7 +155,7 @@ export default class FeatureToggleStore {
return rows.map(this.rowToFeature); return rows.map(this.rowToFeature);
} }
async lastSeenToggles(toggleNames: string[]): Promise<void> { async updateLastSeenForToggles(toggleNames: string[]): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
await this.db(TABLE) await this.db(TABLE)
@ -160,7 +183,7 @@ export default class FeatureToggleStore {
type: row.type, type: row.type,
project: row.project, project: row.project,
stale: row.stale, stale: row.stale,
variants: (row.variants as unknown) as IVariant[], variants: row.variants as unknown as IVariant[],
createdAt: row.created_at, createdAt: row.created_at,
lastSeenAt: row.last_seen_at, lastSeenAt: row.last_seen_at,
}; };
@ -218,7 +241,7 @@ export default class FeatureToggleStore {
return this.rowToFeature(row[0]); return this.rowToFeature(row[0]);
} }
async deleteFeature(name: string): Promise<void> { async delete(name: string): Promise<void> {
await this.db(TABLE) await this.db(TABLE)
.where({ name, archived: true }) // Feature toggle must be archived to allow deletion .where({ name, archived: true }) // Feature toggle must be archived to allow deletion
.del(); .del();
@ -232,14 +255,6 @@ export default class FeatureToggleStore {
return this.rowToFeature(row[0]); return this.rowToFeature(row[0]);
} }
async dropFeatures(): Promise<void> {
try {
await this.db(TABLE).delete();
} catch (err) {
this.logger.error('Could not drop features, error: ', err);
}
}
async getFeaturesBy(params: { async getFeaturesBy(params: {
archived?: boolean; archived?: boolean;
project?: string; project?: string;

View File

@ -1,16 +1,13 @@
import { Knex } from 'knex'; import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import {
IFeatureType,
IFeatureTypeStore,
} from '../types/stores/feature-type-store';
const COLUMNS = ['id', 'name', 'description', 'lifetime_days']; const COLUMNS = ['id', 'name', 'description', 'lifetime_days'];
const TABLE = 'feature_types'; const TABLE = 'feature_types';
export interface IFeatureType {
id: number;
name: string;
description: string;
lifetimeDays: number;
}
interface IFeatureTypeRow { interface IFeatureTypeRow {
id: number; id: number;
name: string; name: string;
@ -18,14 +15,14 @@ interface IFeatureTypeRow {
lifetime_days: number; lifetime_days: number;
} }
class FeatureTypeStore { class FeatureTypeStore implements IFeatureTypeStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) { constructor(db: Knex, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('feature-type-store.js'); this.logger = getLogger('feature-type-store.ts');
} }
async getAll(): Promise<IFeatureType[]> { async getAll(): Promise<IFeatureType[]> {
@ -33,7 +30,7 @@ class FeatureTypeStore {
return rows.map(this.rowToFeatureType); return rows.map(this.rowToFeatureType);
} }
rowToFeatureType(row: IFeatureTypeRow): IFeatureType { private rowToFeatureType(row: IFeatureTypeRow): IFeatureType {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
@ -41,6 +38,35 @@ class FeatureTypeStore {
lifetimeDays: row.lifetime_days, lifetimeDays: row.lifetime_days,
}; };
} }
async get(id: number): Promise<IFeatureType | undefined> {
const row = await this.db(TABLE).where({ id }).first();
return this.rowToFeatureType(row);
}
async getByName(name: string): Promise<IFeatureType> {
const row = await this.db(TABLE).where({ name }).first();
return this.rowToFeatureType(row);
}
async delete(key: number): Promise<void> {
await this.db(TABLE).where({ id: key }).del();
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
destroy(): void {}
async exists(key: number): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`,
[key],
);
const { present } = result.rows[0];
return present;
}
} }
export default FeatureTypeStore; export default FeatureTypeStore;
module.exports = FeatureTypeStore; module.exports = FeatureTypeStore;

View File

@ -1,9 +1,8 @@
// eslint-disable-next-line
import EventEmitter from 'events'; import EventEmitter from 'events';
import { Knex } from 'knex';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
import { IUnleashStores } from '../types/stores'; import { IUnleashStores } from '../types/stores';
import { createDb } from './db-pool';
import EventStore from './event-store'; import EventStore from './event-store';
import FeatureToggleStore from './feature-toggle-store'; import FeatureToggleStore from './feature-toggle-store';
import FeatureTypeStore from './feature-type-store'; import FeatureTypeStore from './feature-type-store';
@ -27,23 +26,27 @@ import UserFeedbackStore from './user-feedback-store';
import FeatureStrategyStore from './feature-strategy-store'; import FeatureStrategyStore from './feature-strategy-store';
import EnvironmentStore from './environment-store'; import EnvironmentStore from './environment-store';
import FeatureTagStore from './feature-tag-store'; import FeatureTagStore from './feature-tag-store';
import { FeatureEnvironmentStore } from './feature-environment-store';
export const createStores = ( export const createStores = (
config: IUnleashConfig, config: IUnleashConfig,
eventBus: EventEmitter, eventBus: EventEmitter,
db: Knex,
): IUnleashStores => { ): IUnleashStores => {
const { getLogger } = config; const { getLogger } = config;
const db = createDb(config);
const eventStore = new EventStore(db, getLogger); const eventStore = new EventStore(db, getLogger);
const clientMetricsDb = new ClientMetricsDb(db, getLogger); const clientMetricsDb = new ClientMetricsDb(db, getLogger);
return { return {
db,
eventStore, eventStore,
featureToggleStore: new FeatureToggleStore(db, eventBus, getLogger), featureToggleStore: new FeatureToggleStore(db, eventBus, getLogger),
featureTypeStore: new FeatureTypeStore(db, getLogger), featureTypeStore: new FeatureTypeStore(db, getLogger),
strategyStore: new StrategyStore(db, getLogger), strategyStore: new StrategyStore(db, getLogger),
clientApplicationsStore: new ClientApplicationsStore(db, eventBus), clientApplicationsStore: new ClientApplicationsStore(
db,
eventBus,
getLogger,
),
clientInstanceStore: new ClientInstanceStore(db, eventBus, getLogger), clientInstanceStore: new ClientInstanceStore(db, eventBus, getLogger),
clientMetricsStore: new ClientMetricsStore( clientMetricsStore: new ClientMetricsStore(
clientMetricsDb, clientMetricsDb,
@ -69,6 +72,11 @@ export const createStores = (
), ),
environmentStore: new EnvironmentStore(db, eventBus, getLogger), environmentStore: new EnvironmentStore(db, eventBus, getLogger),
featureTagStore: new FeatureTagStore(db, eventBus, getLogger), featureTagStore: new FeatureTagStore(db, eventBus, getLogger),
featureEnvironmentStore: new FeatureEnvironmentStore(
db,
eventBus,
getLogger,
),
}; };
}; };

View File

@ -3,44 +3,27 @@ import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { IEnvironmentOverview, IFeatureOverview } from '../types/model'; import { IEnvironmentOverview, IFeatureOverview } from '../types/model';
import {
IProject,
IProjectHealthUpdate,
IProjectInsert,
IProjectStore,
} from '../types/stores/project-store';
const COLUMNS = ['id', 'name', 'description', 'created_at', 'health']; const COLUMNS = ['id', 'name', 'description', 'created_at', 'health'];
const TABLE = 'projects'; const TABLE = 'projects';
export interface IProject { class ProjectStore implements IProjectStore {
id: string;
name: string;
description: string;
health: number;
createdAt: Date;
}
interface IProjectInsert {
id: string;
name: string;
description: string;
}
interface IProjectArchived {
id: string;
archived: boolean;
}
interface IProjectHealthUpdate {
id: string;
health: number;
}
class ProjectStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) { constructor(db: Knex, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('project-store.js'); this.logger = getLogger('project-store.ts');
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
fieldToRow(data): IProjectInsert { fieldToRow(data): IProjectInsert {
return { return {
id: data.id, id: data.id,
@ -49,6 +32,17 @@ class ProjectStore {
}; };
} }
destroy(): void {}
async exists(id: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`,
[id],
);
const { present } = result.rows[0];
return present;
}
async getAll(): Promise<IProject[]> { async getAll(): Promise<IProject[]> {
const rows = await this.db const rows = await this.db
.select(COLUMNS) .select(COLUMNS)
@ -66,20 +60,13 @@ class ProjectStore {
.then(this.mapRow); .then(this.mapRow);
} }
async hasProject(id: string): Promise<IProjectArchived> { async hasProject(id: string): Promise<boolean> {
return this.db const result = await this.db.raw(
.first('id') `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`,
.from(TABLE) [id],
.where({ id }) );
.then(row => { const { present } = result.rows[0];
if (!row) { return present;
throw new NotFoundError(`No project with id=${id} found`);
}
return {
id: row.id,
archived: row.archived === 1,
};
});
} }
async updateHealth(healthUpdate: IProjectHealthUpdate): Promise<void> { async updateHealth(healthUpdate: IProjectHealthUpdate): Promise<void> {
@ -88,13 +75,14 @@ class ProjectStore {
.update({ health: healthUpdate.health }); .update({ health: healthUpdate.health });
} }
async create(project): Promise<IProject> { async create(project: IProjectInsert): Promise<IProject> {
const [id] = await this.db(TABLE) const row = await this.db(TABLE)
.insert(this.fieldToRow(project)) .insert(this.fieldToRow(project))
.returning('id'); .returning('*');
return { ...project, id }; return this.mapRow(row);
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async update(data): Promise<void> { async update(data): Promise<void> {
try { try {
await this.db(TABLE) await this.db(TABLE)
@ -105,7 +93,7 @@ class ProjectStore {
} }
} }
async importProjects(projects): Promise<IProject[]> { async importProjects(projects: IProjectInsert[]): Promise<IProject[]> {
const rows = await this.db(TABLE) const rows = await this.db(TABLE)
.insert(projects.map(this.fieldToRow)) .insert(projects.map(this.fieldToRow))
.returning(COLUMNS) .returning(COLUMNS)
@ -118,8 +106,8 @@ class ProjectStore {
return []; return [];
} }
async addGlobalEnvironment(projects): Promise<void> { async addGlobalEnvironment(projects: any[]): Promise<void> {
const environments = projects.map(p => ({ const environments = projects.map((p) => ({
project_id: p.id, project_id: p.id,
environment_name: ':global:', environment_name: ':global:',
})); }));
@ -129,21 +117,22 @@ class ProjectStore {
.ignore(); .ignore();
} }
async dropProjects(): Promise<void> { async deleteAll(): Promise<void> {
await this.db(TABLE).del(); await this.db(TABLE).del();
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
try { try {
await this.db(TABLE) await this.db(TABLE).where({ id }).del();
.where({ id })
.del();
} catch (err) { } catch (err) {
this.logger.error('Could not delete project, error: ', err); this.logger.error('Could not delete project, error: ', err);
} }
} }
async deleteEnvironment(id: string, environment: string): Promise<void> { async deleteEnvironmentForProject(
id: string,
environment: string,
): Promise<void> {
await this.db('project_environments') await this.db('project_environments')
.where({ .where({
project_id: id, project_id: id,
@ -152,6 +141,24 @@ class ProjectStore {
.del(); .del();
} }
async addEnvironmentToProject(
id: string,
environment: string,
): Promise<void> {
await this.db('project_environments')
.insert({ project_id: id, environment_name: environment })
.onConflict(['project_id', 'environment_name'])
.ignore();
}
async getEnvironmentsForProject(id: string): Promise<string[]> {
return this.db('project_environments')
.where({
project_id: id,
})
.returning('environment_name');
}
async getMembers(projectId: string): Promise<number> { async getMembers(projectId: string): Promise<number> {
const rolesFromProject = this.db('role_permission') const rolesFromProject = this.db('role_permission')
.select('role_id') .select('role_id')
@ -215,7 +222,7 @@ class ProjectStore {
}, {}); }, {});
return Object.values(overview).map((o: IFeatureOverview) => ({ return Object.values(overview).map((o: IFeatureOverview) => ({
...o, ...o,
environments: o.environments.filter(f => f.name), environments: o.environments.filter((f) => f.name),
})); }));
} }
return []; return [];
@ -230,6 +237,7 @@ class ProjectStore {
}; };
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
mapRow(row): IProject { mapRow(row): IProject {
if (!row) { if (!row) {
throw new NotFoundError('No project found'); throw new NotFoundError('No project found');

View File

@ -4,6 +4,13 @@ import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import {
IResetQuery,
IResetToken,
IResetTokenCreate,
IResetTokenQuery,
IResetTokenStore,
} from '../types/stores/reset-token-store';
const TABLE = 'reset_tokens'; const TABLE = 'reset_tokens';
@ -16,44 +23,16 @@ interface IResetTokenTable {
used_at: Date; used_at: Date;
} }
export interface IResetTokenCreate { const rowToResetToken = (row: IResetTokenTable): IResetToken => ({
reset_token: string; userId: row.user_id,
user_id: number; token: row.reset_token,
expires_at: Date; expiresAt: row.expires_at,
created_by?: string; createdAt: row.created_at,
} createdBy: row.created_by,
usedAt: row.used_at,
});
export interface IResetToken { export class ResetTokenStore implements IResetTokenStore {
userId: number;
token: string;
createdBy: string;
expiresAt: Date;
createdAt: Date;
usedAt?: Date;
}
export interface IResetQuery {
userId: number;
token: string;
}
export interface IResetTokenQuery {
user_id: number;
reset_token: string;
}
const rowToResetToken = (row: IResetTokenTable): IResetToken => {
return {
userId: row.user_id,
token: row.reset_token,
expiresAt: row.expires_at,
createdAt: row.created_at,
createdBy: row.created_by,
usedAt: row.used_at,
};
};
export class ResetTokenStore {
private logger: Logger; private logger: Logger;
private timer: Function; private timer: Function;
@ -62,7 +41,7 @@ export class ResetTokenStore {
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('db/reset-token-store.js'); this.logger = getLogger('db/reset-token-store.ts');
this.timer = (action: string) => this.timer = (action: string) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'reset-tokens', store: 'reset-tokens',
@ -113,10 +92,8 @@ export class ResetTokenStore {
} }
} }
async delete({ reset_token }: IResetTokenQuery): Promise<void> { async deleteFromQuery({ reset_token }: IResetTokenQuery): Promise<void> {
return this.db(TABLE) return this.db(TABLE).where(reset_token).del();
.where(reset_token)
.del();
} }
async deleteAll(): Promise<void> { async deleteAll(): Promise<void> {
@ -124,16 +101,37 @@ export class ResetTokenStore {
} }
async deleteExpired(): Promise<void> { async deleteExpired(): Promise<void> {
return this.db(TABLE) return this.db(TABLE).where('expires_at', '<', new Date()).del();
.where('expires_at', '<', new Date())
.del();
} }
async expireExistingTokensForUser(user_id: number): Promise<void> { async expireExistingTokensForUser(user_id: number): Promise<void> {
await this.db<IResetTokenTable>(TABLE) await this.db<IResetTokenTable>(TABLE).where({ user_id }).update({
.where({ user_id }) expires_at: new Date(),
.update({ });
expires_at: new Date(), }
});
async delete(reset_token: string): Promise<void> {
await this.db(TABLE).where({ reset_token }).del();
}
destroy(): void {}
async exists(reset_token: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE reset_token = ?) AS present`,
[reset_token],
);
const { present } = result.rows[0];
return present;
}
async get(key: string): Promise<IResetToken> {
const row = await this.db(TABLE).where({ reset_token: key }).first();
return rowToResetToken(row);
}
async getAll(): Promise<IResetToken[]> {
const rows = await this.db(TABLE).select();
return rows.map(rowToResetToken);
} }
} }

View File

@ -2,6 +2,7 @@ import EventEmitter from 'events';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { ISession, ISessionStore } from '../types/stores/session-store';
const TABLE = 'unleash_session'; const TABLE = 'unleash_session';
@ -12,14 +13,7 @@ interface ISessionRow {
expired?: Date; expired?: Date;
} }
export interface ISession { export default class SessionStore implements ISessionStore {
sid: string;
sess: any;
createdAt: Date;
expired?: Date;
}
export default class SessionStore {
private logger: Logger; private logger: Logger;
private eventBus: EventEmitter; private eventBus: EventEmitter;
@ -41,9 +35,10 @@ export default class SessionStore {
} }
async getSessionsForUser(userId: number): Promise<ISession[]> { async getSessionsForUser(userId: number): Promise<ISession[]> {
const rows = await this.db<ISessionRow>( const rows = await this.db<ISessionRow>(TABLE).whereRaw(
TABLE, "(sess -> 'user' ->> 'id')::int = ?",
).whereRaw(`(sess -> 'user' ->> 'id')::int = ?`, [userId]); [userId],
);
if (rows && rows.length > 0) { if (rows && rows.length > 0) {
return rows.map(this.rowToSession); return rows.map(this.rowToSession);
} }
@ -52,7 +47,7 @@ export default class SessionStore {
); );
} }
async getSession(sid: string): Promise<ISession> { async get(sid: string): Promise<ISession> {
const row = await this.db<ISessionRow>(TABLE) const row = await this.db<ISessionRow>(TABLE)
.where('sid', '=', sid) .where('sid', '=', sid)
.first(); .first();
@ -64,14 +59,12 @@ export default class SessionStore {
async deleteSessionsForUser(userId: number): Promise<void> { async deleteSessionsForUser(userId: number): Promise<void> {
await this.db<ISessionRow>(TABLE) await this.db<ISessionRow>(TABLE)
.whereRaw(`(sess -> 'user' ->> 'id')::int = ?`, [userId]) .whereRaw("(sess -> 'user' ->> 'id')::int = ?", [userId])
.del(); .del();
} }
async deleteSession(sid: string): Promise<void> { async delete(sid: string): Promise<void> {
await this.db<ISessionRow>(TABLE) await this.db<ISessionRow>(TABLE).where('sid', '=', sid).del();
.where('sid', '=', sid)
.del();
} }
async insertSession(data: Omit<ISession, 'createdAt'>): Promise<ISession> { async insertSession(data: Omit<ISession, 'createdAt'>): Promise<ISession> {
@ -92,6 +85,22 @@ export default class SessionStore {
await this.db(TABLE).del(); await this.db(TABLE).del();
} }
destroy(): void {}
async exists(sid: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE sid = ?) AS present`,
[sid],
);
const { present } = result.rows[0];
return present;
}
async getAll(): Promise<ISession[]> {
const rows = await this.db<ISessionRow>(TABLE);
return rows.map(this.rowToSession);
}
private rowToSession(row: ISessionRow): ISession { private rowToSession(row: ISessionRow): ISession {
return { return {
sid: row.sid, sid: row.sid,

View File

@ -1,50 +0,0 @@
/* eslint camelcase: "off" */
'use strict';
const TABLE = 'settings';
class SettingStore {
constructor(db, getLogger) {
this.db = db;
this.logger = getLogger('settings-store.js');
}
async updateRow(name, content) {
return this.db(TABLE)
.where('name', name)
.update({
content: JSON.stringify(content),
});
}
async insertNewRow(name, content) {
return this.db(TABLE).insert({ name, content });
}
async insert(name, content) {
const result = await this.db(TABLE)
.count('*')
.where('name', name)
.first();
if (Number(result.count) > 0) {
return this.updateRow(name, content);
}
return this.insertNewRow(name, content);
}
async get(name) {
const result = await this.db
.select()
.from(TABLE)
.where('name', name);
if (result.length > 0) {
return result[0].content;
}
return undefined;
}
}
module.exports = SettingStore;

View File

@ -0,0 +1,75 @@
import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger';
import { ISettingStore } from '../types/stores/settings-store';
const TABLE = 'settings';
export default class SettingStore implements ISettingStore {
private db: Knex;
private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('settings-store.ts');
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async updateRow(name: string, content: any): Promise<void> {
return this.db(TABLE)
.where('name', name)
.update({
content: JSON.stringify(content),
});
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async insertNewRow(name: string, content: any) {
return this.db(TABLE).insert({ name, content });
}
async exists(name: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`,
[name],
);
const { present } = result.rows[0];
return present;
}
async get(name: string): Promise<any> {
const result = await this.db.select().from(TABLE).where('name', name);
if (result.length > 0) {
return result[0].content;
}
return undefined;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async insert(name: string, content: any): Promise<void> {
const exists = await this.exists(name);
if (exists) {
await this.updateRow(name, content);
} else {
await this.insertNewRow(name, content);
}
}
async delete(name: string): Promise<void> {
await this.db(TABLE).where({ name }).del();
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
destroy(): void {}
async getAll(): Promise<any[]> {
const rows = await this.db(TABLE).select();
return rows.map((r) => r.content);
}
}
module.exports = SettingStore;

View File

@ -2,6 +2,12 @@ import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger'; import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import {
IEditableStrategy,
IMinimalStrategyRow,
IStrategy,
IStrategyStore,
} from '../types/stores/strategy-store';
const STRATEGY_COLUMNS = [ const STRATEGY_COLUMNS = [
'name', 'name',
@ -13,47 +19,25 @@ const STRATEGY_COLUMNS = [
]; ];
const TABLE = 'strategies'; const TABLE = 'strategies';
export interface IStrategy {
name: string;
editable: boolean;
description: string;
parameters: object;
deprecated: boolean;
displayName: string;
}
export interface IEditableStrategy {
name: string;
description: string;
parameters: object;
deprecated: boolean;
}
export interface IMinimalStrategy {
name: string;
description: string;
parameters: string;
}
interface IStrategyRow { interface IStrategyRow {
name: string; name: string;
built_in: number; built_in: number;
description: string; description: string;
parameters: object; parameters: object[];
deprecated: boolean; deprecated: boolean;
display_name: string; display_name: string;
} }
export default class StrategyStore { export default class StrategyStore implements IStrategyStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) { constructor(db: Knex, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('strategy-store.js'); this.logger = getLogger('strategy-store.ts');
} }
async getStrategies(): Promise<IStrategy[]> { async getAll(): Promise<IStrategy[]> {
const rows = await this.db const rows = await this.db
.select(STRATEGY_COLUMNS) .select(STRATEGY_COLUMNS)
.from(TABLE) .from(TABLE)
@ -82,6 +66,30 @@ export default class StrategyStore {
.then(this.rowToStrategy); .then(this.rowToStrategy);
} }
async delete(name: string): Promise<void> {
await this.db(TABLE).where({ name }).del();
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
destroy(): void {}
async exists(name: string): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`,
[name],
);
const { present } = result.rows[0];
return present;
}
async get(name: string): Promise<IStrategy> {
const row = await this.db(TABLE).where({ name }).first();
return this.rowToStrategy(row);
}
rowToStrategy(row: IStrategyRow): IStrategy { rowToStrategy(row: IStrategyRow): IStrategy {
if (!row) { if (!row) {
throw new NotFoundError('No strategy found'); throw new NotFoundError('No strategy found');
@ -109,7 +117,7 @@ export default class StrategyStore {
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
eventDataToRow(data): IMinimalStrategy { eventDataToRow(data): IMinimalStrategyRow {
return { return {
name: data.name, name: data.name,
description: data.description, description: data.description,
@ -119,67 +127,39 @@ export default class StrategyStore {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async createStrategy(data): Promise<void> { async createStrategy(data): Promise<void> {
this.db(TABLE) await this.db(TABLE).insert(this.eventDataToRow(data));
.insert(this.eventDataToRow(data))
.catch(err =>
this.logger.error('Could not insert strategy, error: ', err),
);
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async updateStrategy(data): Promise<void> { async updateStrategy(data): Promise<void> {
this.db(TABLE) await this.db(TABLE)
.where({ name: data.name }) .where({ name: data.name })
.update(this.eventDataToRow(data)) .update(this.eventDataToRow(data));
.catch(err =>
this.logger.error('Could not update strategy, error: ', err),
);
} }
async deprecateStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void> { async deprecateStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void> {
this.db(TABLE) await this.db(TABLE).where({ name }).update({ deprecated: true });
.where({ name })
.update({ deprecated: true })
.catch(err =>
this.logger.error('Could not deprecate strategy, error: ', err),
);
} }
async reactivateStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void> { async reactivateStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void> {
this.db(TABLE) await this.db(TABLE).where({ name }).update({ deprecated: false });
.where({ name })
.update({ deprecated: false })
.catch(err =>
this.logger.error(
'Could not reactivate strategy, error: ',
err,
),
);
} }
async deleteStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void> { async deleteStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void> {
await this.db(TABLE) await this.db(TABLE).where({ name }).del();
.where({ name })
.del()
.catch(err => {
this.logger.error('Could not delete strategy, error: ', err);
});
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async importStrategy(data): Promise<void> { async importStrategy(data): Promise<void> {
const rowData = this.eventDataToRow(data); const rowData = this.eventDataToRow(data);
await this.db(TABLE) await this.db(TABLE).insert(rowData).onConflict(['name']).merge();
.insert(rowData)
.onConflict(['name'])
.merge();
} }
async dropStrategies(): Promise<void> { async dropStrategies(): Promise<void> {
await this.db(TABLE) await this.db(TABLE)
.where({ built_in: 0 }) // eslint-disable-line .where({ built_in: 0 }) // eslint-disable-line
.delete() .delete()
.catch(err => .catch((err) =>
this.logger.error('Could not drop strategies, error: ', err), this.logger.error('Could not drop strategies, error: ', err),
); );
} }

View File

@ -4,6 +4,8 @@ import { DB_TIME } from '../metric-events';
import metricsHelper from '../util/metrics-helper'; import metricsHelper from '../util/metrics-helper';
import { LogProvider, Logger } from '../logger'; import { LogProvider, Logger } from '../logger';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { ITag } from '../types/model';
import { ITagStore } from '../types/stores/tag-store';
const COLUMNS = ['type', 'value']; const COLUMNS = ['type', 'value'];
const TABLE = 'tags'; const TABLE = 'tags';
@ -13,12 +15,7 @@ interface ITagTable {
value: string; value: string;
} }
export interface ITag { export default class TagStore implements ITagStore {
type: string;
value: string;
}
export default class TagStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
@ -27,8 +24,8 @@ export default class TagStore {
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('tag-store.js'); this.logger = getLogger('tag-store.ts');
this.timer = action => this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'tag', store: 'tag',
action, action,
@ -37,10 +34,7 @@ export default class TagStore {
async getTagsByType(type: string): Promise<ITag[]> { async getTagsByType(type: string): Promise<ITag[]> {
const stopTimer = this.timer('getTagByType'); const stopTimer = this.timer('getTagByType');
const rows = await this.db const rows = await this.db.select(COLUMNS).from(TABLE).where({ type });
.select(COLUMNS)
.from(TABLE)
.where({ type });
stopTimer(); stopTimer();
return rows.map(this.rowToTag); return rows.map(this.rowToTag);
} }
@ -84,16 +78,14 @@ export default class TagStore {
stopTimer(); stopTimer();
} }
async deleteTag(tag: ITag): Promise<void> { async delete(tag: ITag): Promise<void> {
const stopTimer = this.timer('deleteTag'); const stopTimer = this.timer('deleteTag');
await this.db(TABLE) await this.db(TABLE).where(tag).del();
.where(tag)
.del();
stopTimer(); stopTimer();
} }
async dropTags(): Promise<void> { async deleteAll(): Promise<void> {
const stopTimer = this.timer('dropTags'); const stopTimer = this.timer('deleteAll');
await this.db(TABLE).del(); await this.db(TABLE).del();
stopTimer(); stopTimer();
} }
@ -106,6 +98,23 @@ export default class TagStore {
.ignore(); .ignore();
} }
destroy(): void {}
async get({ type, value }: ITag): Promise<ITag> {
const stopTimer = this.timer('getTag');
const tag = await this.db
.first(COLUMNS)
.from(TABLE)
.where({ type, value });
stopTimer();
if (!tag) {
throw new NotFoundError(
`No tag with type: [${type}] and value [${value}]`,
);
}
return tag;
}
rowToTag(row: ITagTable): ITag { rowToTag(row: ITagTable): ITag {
return { return {
type: row.type, type: row.type,

View File

@ -4,6 +4,7 @@ import { LogProvider, Logger } from '../logger';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import metricsHelper from '../util/metrics-helper'; import metricsHelper from '../util/metrics-helper';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import { ITagType, ITagTypeStore } from '../types/stores/tag-type-store';
const COLUMNS = ['name', 'description', 'icon']; const COLUMNS = ['name', 'description', 'icon'];
const TABLE = 'tag_types'; const TABLE = 'tag_types';
@ -14,13 +15,7 @@ interface ITagTypeTable {
icon?: string; icon?: string;
} }
export interface ITagType { export default class TagTypeStore implements ITagTypeStore {
name: string;
description?: string;
icon?: string;
}
export default class TagTypeStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
@ -29,8 +24,8 @@ export default class TagTypeStore {
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('tag-type-store.js'); this.logger = getLogger('tag-type-store.ts');
this.timer = action => this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, { metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'tag-type', store: 'tag-type',
action, action,
@ -44,13 +39,13 @@ export default class TagTypeStore {
return rows.map(this.rowToTagType); return rows.map(this.rowToTagType);
} }
async getTagType(name: string): Promise<ITagType> { async get(name: string): Promise<ITagType> {
const stopTimer = this.timer('getTagTypeByName'); const stopTimer = this.timer('getTagTypeByName');
return this.db return this.db
.first(COLUMNS) .first(COLUMNS)
.from(TABLE) .from(TABLE)
.where({ name }) .where({ name })
.then(row => { .then((row) => {
stopTimer(); stopTimer();
if (!row) { if (!row) {
throw new NotFoundError('Could not find tag-type'); throw new NotFoundError('Could not find tag-type');
@ -60,7 +55,7 @@ export default class TagTypeStore {
}); });
} }
async exists(name: string): Promise<Boolean> { async exists(name: string): Promise<boolean> {
const stopTimer = this.timer('exists'); const stopTimer = this.timer('exists');
const result = await this.db.raw( const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`, `SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`,
@ -77,16 +72,14 @@ export default class TagTypeStore {
stopTimer(); stopTimer();
} }
async deleteTagType(name: string): Promise<void> { async delete(name: string): Promise<void> {
const stopTimer = this.timer('deleteTagType'); const stopTimer = this.timer('deleteTagType');
await this.db(TABLE) await this.db(TABLE).where({ name }).del();
.where({ name })
.del();
stopTimer(); stopTimer();
} }
async dropTagTypes(): Promise<void> { async deleteAll(): Promise<void> {
const stopTimer = this.timer('dropTagTypes'); const stopTimer = this.timer('deleteAll');
await this.db(TABLE).del(); await this.db(TABLE).del();
stopTimer(); stopTimer();
} }
@ -105,12 +98,12 @@ export default class TagTypeStore {
async updateTagType({ name, description, icon }: ITagType): Promise<void> { async updateTagType({ name, description, icon }: ITagType): Promise<void> {
const stopTimer = this.timer('updateTagType'); const stopTimer = this.timer('updateTagType');
await this.db(TABLE) await this.db(TABLE).where({ name }).update({ description, icon });
.where({ name })
.update({ description, icon });
stopTimer(); stopTimer();
} }
destroy(): void {}
rowToTagType(row: ITagTypeTable): ITagType { rowToTagType(row: ITagTypeTable): ITagType {
return { return {
name: row.name, name: row.name,

View File

@ -1,6 +1,11 @@
import { Knex } from 'knex'; import { Knex } from 'knex';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { LogProvider, Logger } from '../logger'; import { LogProvider, Logger } from '../logger';
import {
IUserFeedback,
IUserFeedbackKey,
IUserFeedbackStore,
} from '../types/stores/user-feedback-store';
const COLUMNS = ['given', 'user_id', 'feedback_id', 'nevershow']; const COLUMNS = ['given', 'user_id', 'feedback_id', 'nevershow'];
const TABLE = 'user_feedback'; const TABLE = 'user_feedback';
@ -12,13 +17,6 @@ interface IUserFeedbackTable {
user_id: number; user_id: number;
} }
export interface IUserFeedback {
neverShow: boolean;
feedbackId: string;
given?: Date;
userId: number;
}
const fieldToRow = (fields: IUserFeedback): IUserFeedbackTable => ({ const fieldToRow = (fields: IUserFeedback): IUserFeedbackTable => ({
nevershow: fields.neverShow, nevershow: fields.neverShow,
feedback_id: fields.feedbackId, feedback_id: fields.feedbackId,
@ -33,14 +31,14 @@ const rowToField = (row: IUserFeedbackTable): IUserFeedback => ({
userId: row.user_id, userId: row.user_id,
}); });
export default class UserFeedbackStore { export default class UserFeedbackStore implements IUserFeedbackStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('user-feedback-store.js'); this.logger = getLogger('user-feedback-store.ts');
} }
async getAllUserFeedback(userId: number): Promise<IUserFeedback[]> { async getAllUserFeedback(userId: number): Promise<IUserFeedback[]> {
@ -75,6 +73,42 @@ export default class UserFeedbackStore {
return rowToField(insertedFeedback[0]); return rowToField(insertedFeedback[0]);
} }
async delete({ userId, feedbackId }: IUserFeedbackKey): Promise<void> {
await this.db(TABLE)
.where({ user_id: userId, feedback_id: feedbackId })
.del();
}
async deleteAll(): Promise<void> {
await this.db(TABLE).del();
}
destroy(): void {}
async exists({ userId, feedbackId }: IUserFeedbackKey): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE user_id = ? AND feedback_id = ?) AS present`,
[userId, feedbackId],
);
const { present } = result.rows[0];
return present;
}
async get({
userId,
feedbackId,
}: IUserFeedbackKey): Promise<IUserFeedback> {
return this.getFeedback(userId, feedbackId);
}
async getAll(): Promise<IUserFeedback[]> {
const userFeedbacks = await this.db
.table<IUserFeedbackTable>(TABLE)
.select();
return userFeedbacks.map(rowToField);
}
} }
module.exports = UserFeedbackStore; module.exports = UserFeedbackStore;

View File

@ -5,6 +5,13 @@ import { Logger, LogProvider } from '../logger';
import User from '../types/user'; import User from '../types/user';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import {
ICreateUser,
IUserLookup,
IUserSearch,
IUserStore,
IUserUpdateFields,
} from '../types/stores/user-store';
const TABLE = 'users'; const TABLE = 'users';
@ -21,7 +28,7 @@ const USER_COLUMNS = [
const USER_COLUMNS_PUBLIC = ['id', 'name', 'username', 'email', 'image_url']; const USER_COLUMNS_PUBLIC = ['id', 'name', 'username', 'email', 'image_url'];
const emptify = value => { const emptify = (value) => {
if (!value) { if (!value) {
return undefined; return undefined;
} }
@ -37,14 +44,7 @@ const mapUserToColumns = (user: ICreateUser) => ({
image_url: user.imageUrl, image_url: user.imageUrl,
}); });
interface ICreateUser { const rowToUser = (row) => {
name?: string;
username?: string;
email?: string;
imageUrl?: string;
}
const rowToUser = row => {
if (!row) { if (!row) {
throw new NotFoundError('No user found'); throw new NotFoundError('No user found');
} }
@ -60,38 +60,19 @@ const rowToUser = row => {
}); });
}; };
export interface IUserLookup { class UserStore implements IUserStore {
id?: number;
username?: string;
email?: string;
}
export interface IUserSearch {
name?: string;
username?: string;
email: string;
}
export interface IUserUpdateFields {
name?: string;
email?: string;
}
class UserStore {
private db: Knex; private db: Knex;
private logger: Logger; private logger: Logger;
constructor(db: Knex, getLogger: LogProvider) { constructor(db: Knex, getLogger: LogProvider) {
this.db = db; this.db = db;
this.logger = getLogger('user-store.js'); this.logger = getLogger('user-store.ts');
} }
async update(id: number, fields: IUserUpdateFields): Promise<User> { async update(id: number, fields: IUserUpdateFields): Promise<User> {
await this.db(TABLE) await this.db(TABLE).where('id', id).update(mapUserToColumns(fields));
.where('id', id) return this.get(id);
.update(mapUserToColumns(fields));
return this.get({ id });
} }
async insert(user: ICreateUser): Promise<User> { async insert(user: ICreateUser): Promise<User> {
@ -153,15 +134,13 @@ class UserStore {
return users.map(rowToUser); return users.map(rowToUser);
} }
async get(idQuery: IUserLookup): Promise<User> { async getByQuery(idQuery: IUserLookup): Promise<User> {
const row = await this.buildSelectUser(idQuery).first(USER_COLUMNS); const row = await this.buildSelectUser(idQuery).first(USER_COLUMNS);
return rowToUser(row); return rowToUser(row);
} }
async delete(id: number): Promise<void> { async delete(id: number): Promise<void> {
return this.db(TABLE) return this.db(TABLE).where({ id }).del();
.where({ id })
.del();
} }
async getPasswordHash(userId: number): Promise<string> { async getPasswordHash(userId: number): Promise<string> {
@ -177,11 +156,9 @@ class UserStore {
} }
async setPasswordHash(userId: number, passwordHash: string): Promise<void> { async setPasswordHash(userId: number, passwordHash: string): Promise<void> {
return this.db(TABLE) return this.db(TABLE).where('id', userId).update({
.where('id', userId) password_hash: passwordHash,
.update({ });
password_hash: passwordHash,
});
} }
async incLoginAttempts(user: User): Promise<void> { async incLoginAttempts(user: User): Promise<void> {
@ -198,6 +175,22 @@ class UserStore {
async deleteAll(): Promise<void> { async deleteAll(): Promise<void> {
await this.db(TABLE).del(); await this.db(TABLE).del();
} }
destroy(): void {}
async exists(id: number): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ?) AS present`,
[id],
);
const { present } = result.rows[0];
return present;
}
async get(id: number): Promise<User> {
const row = await this.db(TABLE).where({ id }).first();
return rowToUser(row);
}
} }
module.exports = UserStore; module.exports = UserStore;

View File

@ -1,5 +1,3 @@
'use strict';
class InvalidOperationError extends Error { class InvalidOperationError extends Error {
constructor(message: string) { constructor(message: string) {
super(); super();

View File

@ -1,5 +1,3 @@
'use strict';
class NameExistsError extends Error { class NameExistsError extends Error {
constructor(message: string) { constructor(message: string) {
super(); super();

View File

@ -102,7 +102,7 @@ function baseTypeFor(event) {
return event.type; return event.type;
} }
const uniqueFieldForType = baseType => { const uniqueFieldForType = (baseType) => {
if (baseType === 'user') { if (baseType === 'user') {
return 'id'; return 'id';
} }
@ -112,7 +112,7 @@ const uniqueFieldForType = baseType => {
function groupByBaseTypeAndName(events) { function groupByBaseTypeAndName(events) {
const groups = {}; const groups = {};
events.forEach(event => { events.forEach((event) => {
const baseType = baseTypeFor(event); const baseType = baseTypeFor(event);
const uniqueField = uniqueFieldForType(baseType); const uniqueField = uniqueFieldForType(baseType);
@ -129,10 +129,10 @@ function groupByBaseTypeAndName(events) {
function eachConsecutiveEvent(events, callback) { function eachConsecutiveEvent(events, callback) {
const groups = groupByBaseTypeAndName(events); const groups = groupByBaseTypeAndName(events);
Object.keys(groups).forEach(baseType => { Object.keys(groups).forEach((baseType) => {
const group = groups[baseType]; const group = groups[baseType];
Object.keys(group).forEach(name => { Object.keys(group).forEach((name) => {
const currentEvents = group[name]; const currentEvents = group[name];
let left; let left;
let right; let right;

View File

@ -22,7 +22,7 @@ beforeAll(() => {
}); });
[FEATURE_CREATED, FEATURE_UPDATED, FEATURE_ARCHIVED, FEATURE_REVIVED].forEach( [FEATURE_CREATED, FEATURE_UPDATED, FEATURE_ARCHIVED, FEATURE_REVIVED].forEach(
feature => { (feature) => {
test(`should invoke hook on ${feature}`, () => { test(`should invoke hook on ${feature}`, () => {
const data = { dataKey: feature }; const data = { dataKey: feature };
eventStore.emit(feature, data); eventStore.emit(feature, data);

View File

@ -11,16 +11,16 @@ export const addEventHook = (
eventHook: EventHook, eventHook: EventHook,
eventStore: EventEmitter, eventStore: EventEmitter,
): void => { ): void => {
eventStore.on(FEATURE_CREATED, data => { eventStore.on(FEATURE_CREATED, (data) => {
eventHook(FEATURE_CREATED, data); eventHook(FEATURE_CREATED, data);
}); });
eventStore.on(FEATURE_UPDATED, data => { eventStore.on(FEATURE_UPDATED, (data) => {
eventHook(FEATURE_UPDATED, data); eventHook(FEATURE_UPDATED, data);
}); });
eventStore.on(FEATURE_ARCHIVED, data => { eventStore.on(FEATURE_ARCHIVED, (data) => {
eventHook(FEATURE_ARCHIVED, data); eventHook(FEATURE_ARCHIVED, data);
}); });
eventStore.on(FEATURE_REVIVED, data => { eventStore.on(FEATURE_REVIVED, (data) => {
eventHook(FEATURE_REVIVED, data); eventHook(FEATURE_REVIVED, data);
}); });
}; };

View File

@ -1,46 +1,36 @@
'use strict'; import { register } from 'prom-client';
import EventEmitter from 'events';
const { EventEmitter } = require('events'); import { createTestConfig } from '../test/config/test-config';
import { REQUEST_TIME, DB_TIME } from './metric-events';
const eventBus = new EventEmitter(); import { FEATURE_UPDATED } from './types/events';
import { createMetricsMonitor } from './metrics';
const eventStore = new EventEmitter(); import createStores from '../test/fixtures/store';
const clientMetricsStore = new EventEmitter();
const { register: prometheusRegister } = require('prom-client');
const { createTestConfig } = require('../test/config/test-config');
const { REQUEST_TIME, DB_TIME } = require('./metric-events');
const { FEATURE_UPDATED } = require('./types/events');
const { createMetricsMonitor } = require('./metrics');
const monitor = createMetricsMonitor(); const monitor = createMetricsMonitor();
const eventBus = new EventEmitter();
const prometheusRegister = register;
let stores;
beforeAll(() => { beforeAll(() => {
const featureToggleStore = {
count: async () => 123,
};
const config = createTestConfig({ const config = createTestConfig({
server: { server: {
serverMetrics: true, serverMetrics: true,
}, },
}); });
const stores = { stores = createStores();
eventStore, const db = {
clientMetricsStore, client: {
featureToggleStore, pool: {
db: { min: 0,
client: { max: 4,
pool: { numUsed: () => 2,
min: 0, numFree: () => 2,
max: 4, numPendingAcquires: () => 0,
numUsed: () => 2, numPendingCreates: () => 1,
numFree: () => 2,
numPendingAcquires: () => 0,
numPendingCreates: () => 1,
},
}, },
}, },
}; };
monitor.startMonitoring(config, stores, '4.0.0', eventBus); // @ts-ignore - We don't want a full knex implementation for our tests, it's enough that it actually yields the numbers we want.
monitor.startMonitoring(config, stores, '4.0.0', eventBus, db);
}); });
afterAll(() => { afterAll(() => {
@ -57,12 +47,12 @@ test('should collect metrics for requests', async () => {
const metrics = await prometheusRegister.metrics(); const metrics = await prometheusRegister.metrics();
expect(metrics).toMatch( expect(metrics).toMatch(
/http_request_duration_milliseconds{quantile="0\.99",path="somePath",method="GET",status="200"} 1337/, /http_request_duration_milliseconds{quantile="0\.99",path="somePath",method="GET",status="200"}.*1337/,
); );
}); });
test('should collect metrics for updated toggles', async () => { test('should collect metrics for updated toggles', async () => {
eventStore.emit(FEATURE_UPDATED, { stores.eventStore.emit(FEATURE_UPDATED, {
data: { name: 'TestToggle' }, data: { name: 'TestToggle' },
}); });
@ -73,7 +63,7 @@ test('should collect metrics for updated toggles', async () => {
}); });
test('should collect metrics for client metric reports', async () => { test('should collect metrics for client metric reports', async () => {
clientMetricsStore.emit('metrics', { stores.clientMetricsStore.emit('metrics', {
bucket: { bucket: {
toggles: { toggles: {
TestToggle: { TestToggle: {
@ -105,7 +95,7 @@ test('should collect metrics for db query timings', async () => {
test('should collect metrics for feature toggle size', async () => { test('should collect metrics for feature toggle size', async () => {
const metrics = await prometheusRegister.metrics(); const metrics = await prometheusRegister.metrics();
expect(metrics).toMatch(/feature_toggles_total{version="(.*)"} 123/); expect(metrics).toMatch(/feature_toggles_total{version="(.*)"} 0/);
}); });
test('Should collect metrics for database', async () => { test('Should collect metrics for database', async () => {

View File

@ -31,6 +31,7 @@ export default class MetricsMonitor {
stores: IUnleashStores, stores: IUnleashStores,
version: string, version: string,
eventBus: EventEmitter, eventBus: EventEmitter,
db: Knex,
): Promise<void> { ): Promise<void> {
if (!config.server.serverMetrics) { if (!config.server.serverMetrics) {
return; return;
@ -72,7 +73,9 @@ export default class MetricsMonitor {
featureTogglesTotal.reset(); featureTogglesTotal.reset();
let togglesCount; let togglesCount;
try { try {
togglesCount = await featureToggleStore.count(); togglesCount = await featureToggleStore.count({
archived: false,
});
// eslint-disable-next-line no-empty // eslint-disable-next-line no-empty
} catch (e) {} } catch (e) {}
@ -110,7 +113,7 @@ export default class MetricsMonitor {
featureToggleUpdateTotal.labels(data.name).inc(); featureToggleUpdateTotal.labels(data.name).inc();
}); });
clientMetricsStore.on('metrics', m => { clientMetricsStore.on('metrics', (m) => {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
for (const entry of Object.entries(m.bucket.toggles)) { for (const entry of Object.entries(m.bucket.toggles)) {
featureToggleUsageTotal featureToggleUsageTotal
@ -124,7 +127,7 @@ export default class MetricsMonitor {
} }
}); });
this.configureDbMetrics(stores.db, eventBus); this.configureDbMetrics(db, eventBus);
} }
stopMonitoring(): void { stopMonitoring(): void {
@ -154,16 +157,14 @@ export default class MetricsMonitor {
}); });
const dbPoolPendingCreates = new client.Gauge({ const dbPoolPendingCreates = new client.Gauge({
name: 'db_pool_pending_creates', name: 'db_pool_pending_creates',
help: help: 'how many asynchronous create calls are running in DB pool',
'how many asynchronous create calls are running in DB pool',
}); });
const dbPoolPendingAcquires = new client.Gauge({ const dbPoolPendingAcquires = new client.Gauge({
name: 'db_pool_pending_acquires', name: 'db_pool_pending_acquires',
help: help: 'how many acquires are waiting for a resource to be released in DB pool',
'how many acquires are waiting for a resource to be released in DB pool',
}); });
eventBus.on(DB_POOL_UPDATE, data => { eventBus.on(DB_POOL_UPDATE, (data) => {
dbPoolFree.set(data.free); dbPoolFree.set(data.free);
dbPoolUsed.set(data.used); dbPoolUsed.set(data.used);
dbPoolPendingCreates.set(data.pendingCreates); dbPoolPendingCreates.set(data.pendingCreates);

View File

@ -1,7 +1,9 @@
const requireContentType = require('./content_type_checker'); import { Request, Response } from 'express';
import requireContentType from './content_type_checker';
const mockRequest = contentType => ({ const mockRequest: (contentType: string) => Request = (contentType) => ({
header: name => { // @ts-ignore
header: (name) => {
if (name === 'Content-Type') { if (name === 'Content-Type') {
return contentType; return contentType;
} }
@ -9,8 +11,9 @@ const mockRequest = contentType => ({
}, },
}); });
const returns415 = t => ({ const returns415: (t: jest.Mock) => Response = (t) => ({
status: code => { // @ts-ignore
status: (code) => {
expect(415).toBe(code); expect(415).toBe(code);
return { return {
end: t, end: t,
@ -18,7 +21,8 @@ const returns415 = t => ({
}, },
}); });
const expectNoCall = t => ({ const expectNoCall: (t: jest.Mock) => Response = (t) => ({
// @ts-ignore
status: () => ({ status: () => ({
end: () => expect(t).toHaveBeenCalledTimes(0), end: () => expect(t).toHaveBeenCalledTimes(0),
}), }),

View File

@ -1,3 +1,5 @@
import { RequestHandler } from 'express';
const DEFAULT_ACCEPTED_CONTENT_TYPE = 'application/json'; const DEFAULT_ACCEPTED_CONTENT_TYPE = 'application/json';
/** /**
@ -7,7 +9,9 @@ const DEFAULT_ACCEPTED_CONTENT_TYPE = 'application/json';
* @param {String} acceptedContentTypes * @param {String} acceptedContentTypes
* @returns {function(Request, Response, NextFunction): void} * @returns {function(Request, Response, NextFunction): void}
*/ */
function requireContentType(...acceptedContentTypes) { export default function requireContentType(
...acceptedContentTypes: string[]
): RequestHandler {
return (req, res, next) => { return (req, res, next) => {
const contentType = req.header('Content-Type'); const contentType = req.header('Content-Type');
if ( if (

View File

@ -1,28 +1,42 @@
const AuthenticationRequired = require('../types/authentication-required'); import { Application } from 'express';
import AuthenticationRequired from '../types/authentication-required';
import { IUnleashServices } from '../types/services';
function demoAuthentication(app, basePath = '', { userService }) { function demoAuthentication(
app: Application,
basePath: string = '',
{ userService }: Pick<IUnleashServices, 'userService'>,
): void {
app.post(`${basePath}/api/admin/login`, async (req, res) => { app.post(`${basePath}/api/admin/login`, async (req, res) => {
const { email } = req.body; const { email } = req.body;
const user = await userService.loginUserWithoutPassword(email, true); const user = await userService.loginUserWithoutPassword(email, true);
req.session.user = user; const session = req.session || {};
// @ts-ignore
session.user = user;
// @ts-ignore
req.session = session;
res.status(200) res.status(200)
// @ts-ignore
.json(req.session.user) .json(req.session.user)
.end(); .end();
}); });
app.use(`${basePath}/api/admin/`, (req, res, next) => { app.use(`${basePath}/api/admin/`, (req, res, next) => {
// @ts-ignore
if (req.session.user && req.session.user.email) { if (req.session.user && req.session.user.email) {
// @ts-ignore
req.user = req.session.user; req.user = req.session.user;
} }
next(); next();
}); });
app.use(`${basePath}/api/admin/`, (req, res, next) => { app.use(`${basePath}/api/admin/`, (req, res, next) => {
// @ts-ignore
if (req.user) { if (req.user) {
return next(); return next();
} }
return res return res
.status('401') .status(401)
.json( .json(
new AuthenticationRequired({ new AuthenticationRequired({
path: `${basePath}/api/admin/login`, path: `${basePath}/api/admin/login`,
@ -34,5 +48,4 @@ function demoAuthentication(app, basePath = '', { userService }) {
.end(); .end();
}); });
} }
export default demoAuthentication;
module.exports = demoAuthentication;

View File

@ -1,18 +0,0 @@
'use strict';
const { ADMIN } = require('../types/permissions');
const ApiUser = require('../types/api-user');
function noneAuthentication(basePath = '', app) {
app.use(`${basePath}/api/admin/`, (req, res, next) => {
if (!req.user) {
req.user = new ApiUser({
username: 'unknown',
permissions: [ADMIN],
});
}
next();
});
}
module.exports = noneAuthentication;

View File

@ -1,28 +1,24 @@
'use strict'; import supertest from 'supertest';
import express from 'express';
const supertest = require('supertest'); import noAuthentication from './no-authentication';
const express = require('express'); import { IUserRequest } from '../routes/admin-api/user';
const noAuthentication = require('./no-authentication');
test('should add dummy user object to all requests', () => { test('should add dummy user object to all requests', () => {
expect.assertions(1); expect.assertions(1);
const app = express(); const app = express();
noAuthentication('', app); noAuthentication('', app);
app.get('/api/admin/test', (req, res) => { app.get('/api/admin/test', (req: IUserRequest<any, any, any, any>, res) => {
const user = { ...req.user }; const user = { ...req.user };
return res return res.status(200).json(user).end();
.status(200)
.json(user)
.end();
}); });
const request = supertest(app); const request = supertest(app);
return request return request
.get('/api/admin/test') .get('/api/admin/test')
.expect(200) .expect(200)
.expect(res => { .expect((res) => {
expect(res.body.username === 'unknown').toBe(true); expect(res.body.username === 'unknown').toBe(true);
}); });
}); });

View File

@ -0,0 +1,18 @@
import { Application } from 'express';
import { ADMIN } from '../types/permissions';
import ApiUser from '../types/api-user';
function noneAuthentication(basePath = '', app: Application): void {
app.use(`${basePath}/api/admin/`, (req, res, next) => {
// @ts-ignore
if (!req.user) {
// @ts-ignore
req.user = new ApiUser({
username: 'unknown',
permissions: [ADMIN],
});
}
next();
});
}
export default noneAuthentication;

View File

@ -1,31 +0,0 @@
const AuthenticationRequired = require('../types/authentication-required');
function ossAuthHook(app, config) {
const { baseUriPath } = config.server;
const generateAuthResponse = async () =>
new AuthenticationRequired({
type: 'password',
path: `${baseUriPath}/auth/simple/login`,
message: 'You must sign in order to use Unleash',
});
app.use(`${baseUriPath}/api`, async (req, res, next) => {
if (req.session && req.session.user) {
req.user = req.session.user;
return next();
}
if (req.user) {
return next();
}
if (req.header('authorization')) {
// API clients should get 401 without body
return res.sendStatus(401);
}
// Admin UI users should get auth-response
const authRequired = await generateAuthResponse();
return res.status(401).json(authRequired);
});
}
module.exports = ossAuthHook;

View File

@ -1,14 +1,13 @@
'use strict'; import supertest from 'supertest';
import { EventEmitter } from 'events';
import { createServices } from '../services';
import { createTestConfig } from '../../test/config/test-config';
const supertest = require('supertest'); import createStores from '../../test/fixtures/store';
const { EventEmitter } = require('events'); import ossAuth from './oss-authentication';
const { createServices } = require('../services'); import getApp from '../app';
const { createTestConfig } = require('../../test/config/test-config'); import User from '../types/user';
import sessionDb from './session-db';
const store = require('../../test/fixtures/store');
const ossAuth = require('./oss-authentication');
const getApp = require('../app');
const { User } = require('../server-impl');
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
@ -16,19 +15,18 @@ function getSetup(preRouterHook) {
const base = `/random${Math.round(Math.random() * 1000)}`; const base = `/random${Math.round(Math.random() * 1000)}`;
const config = createTestConfig({ const config = createTestConfig({
server: { baseUriPath: base }, server: { baseUriPath: base },
preRouterHook: _app => { preRouterHook: (_app) => {
preRouterHook(_app); preRouterHook(_app);
ossAuth(_app, { server: { baseUriPath: base } }); ossAuth(_app, base);
_app.get(`${base}/api/protectedResource`, (req, res) => { _app.get(`${base}/api/protectedResource`, (req, res) => {
res.status(200) res.status(200).json({ message: 'OK' }).end();
.json({ message: 'OK' })
.end();
}); });
}, },
}); });
const stores = store.createStores(); const stores = createStores();
const services = createServices(stores, config); const services = createServices(stores, config);
const app = getApp(config, stores, services, eventBus); const unleashSession = sessionDb(config, undefined);
const app = getApp(config, stores, services, eventBus, unleashSession);
return { return {
base, base,
@ -46,7 +44,7 @@ test('should return 401 when missing user', () => {
test('should return 200 when user exists', () => { test('should return 200 when user exists', () => {
expect.assertions(0); expect.assertions(0);
const user = new User({ id: 1, email: 'some@mail.com' }); const user = new User({ id: 1, email: 'some@mail.com' });
const { base, request } = getSetup(app => const { base, request } = getSetup((app) =>
app.use((req, res, next) => { app.use((req, res, next) => {
req.user = user; req.user = user;
next(); next();

View File

@ -0,0 +1,35 @@
import { Application, NextFunction, Request, Response } from 'express';
import AuthenticationRequired from '../types/authentication-required';
function ossAuthHook(app: Application, baseUriPath: string): void {
const generateAuthResponse = async () =>
new AuthenticationRequired({
type: 'password',
path: `${baseUriPath}/auth/simple/login`,
message: 'You must sign in order to use Unleash',
});
app.use(
`${baseUriPath}/api`,
async (req: Request, res: Response, next: NextFunction) => {
// @ts-ignore
if (req.session && req.session.user) {
// @ts-ignore
req.user = req.session.user;
return next();
}
// @ts-ignore
if (req.user) {
return next();
}
if (req.header('authorization')) {
// API clients should get 401 without body
return res.sendStatus(401);
}
// Admin UI users should get auth-response
const authRequired = await generateAuthResponse();
return res.status(401).json(authRequired);
},
);
}
export default ossAuthHook;

View File

@ -1,16 +1,17 @@
import rbacMiddleware from './rbac-middleware'; import rbacMiddleware from './rbac-middleware';
import ffStore from '../../test/fixtures/fake-feature-toggle-store';
import User from '../types/user'; import User from '../types/user';
import * as perms from '../types/permissions'; import * as perms from '../types/permissions';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
import { createTestConfig } from '../../test/config/test-config'; import { createTestConfig } from '../../test/config/test-config';
import ApiUser from '../types/api-user'; import ApiUser from '../types/api-user';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import FakeFeatureToggleStore from '../../test/fixtures/fake-feature-toggle-store';
let config: IUnleashConfig; let config: IUnleashConfig;
let featureToggleStore: any; let featureToggleStore: IFeatureToggleStore;
beforeEach(() => { beforeEach(() => {
featureToggleStore = ffStore(); featureToggleStore = new FakeFeatureToggleStore();
config = createTestConfig(); config = createTestConfig();
}); });
@ -206,7 +207,6 @@ test('should lookup projectId from feature toggle', async () => {
perms.UPDATE_FEATURE, perms.UPDATE_FEATURE,
projectId, projectId,
); );
expect(featureToggleStore.getProjectId.mock.calls[0][0]).toBe(featureName);
}); });
test('should lookup projectId from data', async () => { test('should lookup projectId from data', async () => {

View File

@ -1,8 +1,8 @@
'use strict'; import url from 'url';
import { RequestHandler } from 'express';
import { IUnleashConfig } from '../types/option';
const url = require('url'); const requestLogger: (config: IUnleashConfig) => RequestHandler = (config) => {
module.exports = function(config) {
const logger = config.getLogger('HTTP'); const logger = config.getLogger('HTTP');
const enable = config.server.enableRequestLogger; const enable = config.server.enableRequestLogger;
return (req, res, next) => { return (req, res, next) => {
@ -15,3 +15,5 @@ module.exports = function(config) {
next(); next();
}; };
}; };
export default requestLogger;

View File

@ -1,6 +1,8 @@
const helmet = require('helmet'); import helmet from 'helmet';
import { RequestHandler } from 'express';
import { IUnleashConfig } from '../types/option';
module.exports = function(config) { const secureHeaders: (config: IUnleashConfig) => RequestHandler = (config) => {
if (config.secureHeaders) { if (config.secureHeaders) {
return helmet({ return helmet({
hsts: { hsts: {
@ -33,3 +35,5 @@ module.exports = function(config) {
next(); next();
}; };
}; };
export default secureHeaders;

View File

@ -1,28 +1,30 @@
import { Knex } from 'knex';
import session from 'express-session';
import knexSessionStore from 'connect-session-knex';
import { RequestHandler } from 'express';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
import { IUnleashStores } from '../types/stores';
const session = require('express-session');
const KnexSessionStore = require('connect-session-knex')(session);
const TWO_DAYS = 48 * 60 * 60 * 1000; const TWO_DAYS = 48 * 60 * 60 * 1000;
const HOUR = 60 * 60 * 1000; const HOUR = 60 * 60 * 1000;
function sessionDb( function sessionDb(
config: Pick<IUnleashConfig, 'session' | 'server' | 'secureHeaders'>, config: Pick<IUnleashConfig, 'session' | 'server' | 'secureHeaders'>,
stores: Pick<IUnleashStores, 'db'>, knex: Knex,
): any { ): RequestHandler {
let store; let store;
const { db } = config.session; const { db } = config.session;
const age = config.session.ttlHours * HOUR || TWO_DAYS; const age = config.session.ttlHours * HOUR || TWO_DAYS;
const KnexSessionStore = knexSessionStore(session);
if (db) { if (db) {
store = new KnexSessionStore({ store = new KnexSessionStore({
knex: stores.db,
tablename: 'unleash_session', tablename: 'unleash_session',
createtable: false, createtable: false,
// @ts-ignore
knex,
}); });
} else { } else {
store = new session.MemoryStore(); store = new session.MemoryStore();
} }
const sessionMiddleware = session({ return session({
name: 'unleash-session', name: 'unleash-session',
rolling: false, rolling: false,
resave: false, resave: false,
@ -38,7 +40,5 @@ function sessionDb(
maxAge: age, maxAge: age,
}, },
}); });
return (req, res, next) => sessionMiddleware(req, res, next);
} }
export default sessionDb; export default sessionDb;
module.exports = sessionDb;

View File

@ -1,5 +1,3 @@
'use strict';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import Controller from '../controller'; import Controller from '../controller';
import { IUnleashConfig } from '../../types/option'; import { IUnleashConfig } from '../../types/option';
@ -9,7 +7,11 @@ import AddonService from '../../services/addon-service';
import extractUser from '../../extract-user'; import extractUser from '../../extract-user';
import { handleErrors } from './util'; import { handleErrors } from './util';
import { CREATE_ADDON, UPDATE_ADDON, DELETE_ADDON } from '../../types/permissions'; import {
CREATE_ADDON,
UPDATE_ADDON,
DELETE_ADDON,
} from '../../types/permissions';
class AddonController extends Controller { class AddonController extends Controller {
private logger: Logger; private logger: Logger;
@ -21,7 +23,7 @@ class AddonController extends Controller {
{ addonService }: Pick<IUnleashServices, 'addonService'>, { addonService }: Pick<IUnleashServices, 'addonService'>,
) { ) {
super(config); super(config);
this.logger = config.getLogger('/admin-api/addon.js'); this.logger = config.getLogger('/admin-api/addon.ts');
this.addonService = addonService; this.addonService = addonService;
this.get('/', this.getAddons); this.get('/', this.getAddons);
@ -34,14 +36,17 @@ class AddonController extends Controller {
async getAddons(req: Request, res: Response): Promise<void> { async getAddons(req: Request, res: Response): Promise<void> {
try { try {
const addons = await this.addonService.getAddons(); const addons = await this.addonService.getAddons();
const providers = await this.addonService.getProviderDefinition(); const providers = this.addonService.getProviderDefinitions();
res.json({ addons, providers }); res.json({ addons, providers });
} catch (error) { } catch (error) {
handleErrors(res, this.logger, error); handleErrors(res, this.logger, error);
} }
} }
async getAddon(req: Request, res: Response): Promise<void> { async getAddon(
req: Request<{ id: number }, any, any, any>,
res: Response,
): Promise<void> {
const { id } = req.params; const { id } = req.params;
try { try {
const addon = await this.addonService.getAddon(id); const addon = await this.addonService.getAddon(id);
@ -51,7 +56,10 @@ class AddonController extends Controller {
} }
} }
async updateAddon(req: Request, res: Response): Promise<void> { async updateAddon(
req: Request<{ id: number }, any, any, any>,
res: Response,
): Promise<void> {
const { id } = req.params; const { id } = req.params;
const createdBy = extractUser(req); const createdBy = extractUser(req);
const data = req.body; const data = req.body;
@ -79,7 +87,10 @@ class AddonController extends Controller {
} }
} }
async deleteAddon(req: Request, res: Response): Promise<void> { async deleteAddon(
req: Request<{ id: number }, any, any, any>,
res: Response,
): Promise<void> {
const { id } = req.params; const { id } = req.params;
const username = extractUser(req); const username = extractUser(req);
try { try {

View File

@ -9,11 +9,11 @@ import {
} from '../../types/permissions'; } from '../../types/permissions';
import { ApiTokenService } from '../../services/api-token-service'; import { ApiTokenService } from '../../services/api-token-service';
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import { ApiTokenType } from '../../db/api-token-store';
import { AccessService } from '../../services/access-service'; import { AccessService } from '../../services/access-service';
import { IAuthRequest } from '../unleash-types'; import { IAuthRequest } from '../unleash-types';
import User from '../../types/user'; import User from '../../types/user';
import { IUnleashConfig } from '../../types/option'; import { IUnleashConfig } from '../../types/option';
import { ApiTokenType } from '../../types/stores/api-token-store';
interface IServices { interface IServices {
apiTokenService: ApiTokenService; apiTokenService: ApiTokenService;
@ -57,7 +57,7 @@ class ApiTokenController extends Controller {
res.json({ tokens }); res.json({ tokens });
} else { } else {
const filteredTokens = tokens.filter( const filteredTokens = tokens.filter(
t => !(t.type === ApiTokenType.ADMIN), (t) => !(t.type === ApiTokenType.ADMIN),
); );
res.json({ tokens: filteredTokens }); res.json({ tokens: filteredTokens });
} }

View File

@ -37,9 +37,8 @@ export default class ArchiveController extends Controller {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async getArchivedFeatures(req, res): Promise<void> { async getArchivedFeatures(req, res): Promise<void> {
try { try {
const features = await this.featureService.getMetadataForAllFeatures( const features =
true, await this.featureService.getMetadataForAllFeatures(true);
);
res.json({ version: 2, features }); res.json({ version: 2, features });
} catch (err) { } catch (err) {
handleErrors(res, this.logger, err); handleErrors(res, this.logger, err);

View File

@ -3,15 +3,9 @@ import Controller from '../controller';
import { AuthedRequest } from '../../types/core'; import { AuthedRequest } from '../../types/core';
import { Logger } from '../../logger'; import { Logger } from '../../logger';
import ContextService from '../../services/context-service'; import ContextService from '../../services/context-service';
import FeatureTypeStore, { IFeatureType } from '../../db/feature-type-store';
import TagTypeService from '../../services/tag-type-service'; import TagTypeService from '../../services/tag-type-service';
import StrategyService from '../../services/strategy-service'; import StrategyService from '../../services/strategy-service';
import ProjectService from '../../services/project-service'; import ProjectService from '../../services/project-service';
import { IContextField } from '../../db/context-field-store';
import { ITagType } from '../../db/tag-type-store';
import { IProject } from '../../db/project-store';
import { IStrategy } from '../../db/strategy-store';
import { IUserPermission } from '../../db/access-store';
import { AccessService } from '../../services/access-service'; import { AccessService } from '../../services/access-service';
import { EmailService } from '../../services/email-service'; import { EmailService } from '../../services/email-service';
import { IUnleashConfig } from '../../types/option'; import { IUnleashConfig } from '../../types/option';
@ -19,6 +13,12 @@ import { IUnleashServices } from '../../types/services';
import VersionService from '../../services/version-service'; import VersionService from '../../services/version-service';
import FeatureTypeService from '../../services/feature-type-service'; import FeatureTypeService from '../../services/feature-type-service';
import version from '../../util/version'; import version from '../../util/version';
import { IContextField } from '../../types/stores/context-field-store';
import { IFeatureType } from '../../types/stores/feature-type-store';
import { ITagType } from '../../types/stores/tag-type-store';
import { IStrategy } from '../../types/stores/strategy-store';
import { IProject } from '../../types/stores/project-store';
import { IUserPermission } from '../../types/stores/access-store';
class BootstrapController extends Controller { class BootstrapController extends Controller {
private logger: Logger; private logger: Logger;

View File

@ -1,12 +1,10 @@
'use strict'; import supertest from 'supertest';
import { EventEmitter } from 'events';
import { createTestConfig } from '../../../test/config/test-config';
const supertest = require('supertest'); import createStores from '../../../test/fixtures/store';
const { EventEmitter } = require('events'); import getApp from '../../app';
const { createServices } = require('../../services'); import { createServices } from '../../services';
const { createTestConfig } = require('../../../test/config/test-config');
const store = require('../../../test/fixtures/store');
const getApp = require('../../app');
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
@ -21,7 +19,7 @@ function getSetup() {
server: { baseUriPath: base }, server: { baseUriPath: base },
ui: uiConfig, ui: uiConfig,
}); });
const stores = store.createStores(); const stores = createStores();
const services = createServices(stores, config); const services = createServices(stores, config);
const app = getApp(config, stores, services, eventBus); const app = getApp(config, stores, services, eventBus);
@ -57,7 +55,7 @@ test('should get ui config', () => {
.get(`${base}/api/admin/ui-config`) .get(`${base}/api/admin/ui-config`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect(res => { .expect((res) => {
expect(res.body.slogan === 'hello').toBe(true); expect(res.body.slogan === 'hello').toBe(true);
expect(res.body.headerBackground === 'red').toBe(true); expect(res.body.headerBackground === 'red').toBe(true);
}); });

View File

@ -1,5 +1,3 @@
'use strict';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { IUnleashServices } from '../../types/services'; import { IUnleashServices } from '../../types/services';
import { IUnleashConfig } from '../../types/option'; import { IUnleashConfig } from '../../types/option';

View File

@ -1,12 +1,10 @@
'use strict'; import supertest from 'supertest';
import { EventEmitter } from 'events';
const supertest = require('supertest'); import { createTestConfig } from '../../../test/config/test-config';
const { EventEmitter } = require('events'); import createStores from '../../../test/fixtures/store';
const { createTestConfig } = require('../../../test/config/test-config'); import { createServices } from '../../services';
const store = require('../../../test/fixtures/store'); import permissions from '../../../test/fixtures/permissions';
const { createServices } = require('../../services'); import getApp from '../../app';
const permissions = require('../../../test/fixtures/permissions');
const getApp = require('../../app');
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
@ -17,7 +15,7 @@ function getSetup() {
preHook: perms.hook, preHook: perms.hook,
server: { baseUriPath: base }, server: { baseUriPath: base },
}); });
const stores = store.createStores(); const stores = createStores();
const services = createServices(stores, config); const services = createServices(stores, config);
const app = getApp(config, stores, services, eventBus); const app = getApp(config, stores, services, eventBus);
@ -54,9 +52,9 @@ test('should get all context definitions', () => {
.get(`${base}/api/admin/context`) .get(`${base}/api/admin/context`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect(res => { .expect((res) => {
expect(res.body.length === 3).toBe(true); expect(res.body.length === 3).toBe(true);
const envField = res.body.find(c => c.name === 'environment'); const envField = res.body.find((c) => c.name === 'environment');
expect(envField.name === 'environment').toBe(true); expect(envField.name === 'environment').toBe(true);
}); });
}); });
@ -67,7 +65,7 @@ test('should get context definition', () => {
.get(`${base}/api/admin/context/userId`) .get(`${base}/api/admin/context/userId`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect(res => { .expect((res) => {
expect(res.body.name).toBe('userId'); expect(res.body.name).toBe('userId');
}); });
}); });

View File

@ -47,9 +47,7 @@ class ContextController extends Controller {
async getContextFields(req: Request, res: Response): Promise<void> { async getContextFields(req: Request, res: Response): Promise<void> {
try { try {
const fields = await this.contextService.getAll(); const fields = await this.contextService.getAll();
res.status(200) res.status(200).json(fields).end();
.json(fields)
.end();
} catch (e) { } catch (e) {
handleErrors(res, this.logger, e); handleErrors(res, this.logger, e);
} }

View File

@ -1,18 +1,16 @@
'use strict'; import supertest from 'supertest';
import { EventEmitter } from 'events';
const supertest = require('supertest'); import { createTestConfig } from '../../../test/config/test-config';
const { EventEmitter } = require('events'); import createStores from '../../../test/fixtures/store';
const { createTestConfig } = require('../../../test/config/test-config'); import { createServices } from '../../services';
const store = require('../../../test/fixtures/store'); import permissions from '../../../test/fixtures/permissions';
const { createServices } = require('../../services'); import getApp from '../../app';
const permissions = require('../../../test/fixtures/permissions');
const getApp = require('../../app');
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
function getSetup() { function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`; const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores(); const stores = createStores();
const perms = permissions(); const perms = permissions();
const config = createTestConfig({ const config = createTestConfig({
preHook: perms.hook, preHook: perms.hook,
@ -37,7 +35,7 @@ test('should render html preview of template', () => {
) )
.expect('Content-Type', /html/) .expect('Content-Type', /html/)
.expect(200) .expect(200)
.expect(res => 'Test Test' in res.body); .expect((res) => 'Test Test' in res.body);
}); });
test('should render text preview of template', () => { test('should render text preview of template', () => {
@ -49,7 +47,7 @@ test('should render text preview of template', () => {
) )
.expect('Content-Type', /plain/) .expect('Content-Type', /plain/)
.expect(200) .expect(200)
.expect(res => 'Test Test' in res.body); .expect((res) => 'Test Test' in res.body);
}); });
test('Requesting a non-existing template should yield 404', () => { test('Requesting a non-existing template should yield 404', () => {

View File

@ -1,5 +1,3 @@
'use strict';
import { handleErrors } from './util'; import { handleErrors } from './util';
import { IUnleashConfig } from '../../types/option'; import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types/services'; import { IUnleashServices } from '../../types/services';

View File

@ -1,19 +1,17 @@
'use strict'; import supertest from 'supertest';
import { EventEmitter } from 'events';
import { createServices } from '../../services';
import { createTestConfig } from '../../../test/config/test-config';
const supertest = require('supertest'); import createStores from '../../../test/fixtures/store';
const { EventEmitter } = require('events');
const { createServices } = require('../../services');
const { createTestConfig } = require('../../../test/config/test-config');
const store = require('../../../test/fixtures/store'); import getApp from '../../app';
const getApp = require('../../app');
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
function getSetup() { function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`; const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores(); const stores = createStores();
const config = createTestConfig({ const config = createTestConfig({
server: { baseUriPath: base }, server: { baseUriPath: base },
}); });
@ -30,7 +28,7 @@ test('should get empty events list via admin', () => {
.get(`${base}/api/admin/events`) .get(`${base}/api/admin/events`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect(res => { .expect((res) => {
expect(res.body.events.length === 0).toBe(true); expect(res.body.events.length === 0).toBe(true);
}); });
}); });

View File

@ -1,5 +1,4 @@
'use strict'; import { Request, Response } from 'express';
import { handleErrors } from './util'; import { handleErrors } from './util';
import { IUnleashServices } from '../../types/services'; import { IUnleashServices } from '../../types/services';
import FeatureTypeService from '../../services/feature-type-service'; import FeatureTypeService from '../../services/feature-type-service';
@ -26,7 +25,7 @@ export default class FeatureTypeController extends Controller {
this.get('/', this.getAllFeatureTypes); this.get('/', this.getAllFeatureTypes);
} }
async getAllFeatureTypes(req, res) { async getAllFeatureTypes(req: Request, res: Response): Promise<void> {
try { try {
const types = await this.featureTypeService.getAll(); const types = await this.featureTypeService.getAll();
res.json({ version, types }); res.json({ version, types });

View File

@ -34,8 +34,8 @@ class FeatureController extends Controller {
featureTagService, featureTagService,
featureToggleServiceV2, featureToggleServiceV2,
}: Pick< }: Pick<
IUnleashServices, IUnleashServices,
'featureTagService' | 'featureToggleServiceV2' 'featureTagService' | 'featureToggleServiceV2'
>, >,
) { ) {
super(config); super(config);
@ -87,7 +87,7 @@ class FeatureController extends Controller {
namePrefix, namePrefix,
}); });
if (query.tag) { if (query.tag) {
query.tag = query.tag.map(q => q.split(':')); query.tag = query.tag.map((q) => q.split(':'));
} }
return query; return query;
} }
@ -113,7 +113,7 @@ class FeatureController extends Controller {
const name = req.params.featureName; const name = req.params.featureName;
const feature = await this.featureService2.getFeatureToggle(name); const feature = await this.featureService2.getFeatureToggle(name);
const strategies = const strategies =
feature.environments.find(e => e.name === GLOBAL_ENV) feature.environments.find((e) => e.name === GLOBAL_ENV)
?.strategies || []; ?.strategies || [];
res.json({ res.json({
...feature, ...feature,
@ -186,13 +186,14 @@ class FeatureController extends Controller {
try { try {
const validatedToggle = await featureSchema.validateAsync(toggle); const validatedToggle = await featureSchema.validateAsync(toggle);
const { enabled } = validatedToggle; const { enabled } = validatedToggle;
const createdFeature = await this.featureService2.createFeatureToggle( const createdFeature =
validatedToggle.project, await this.featureService2.createFeatureToggle(
validatedToggle, validatedToggle.project,
userName, validatedToggle,
); userName,
);
const strategies = await Promise.all( const strategies = await Promise.all(
toggle.strategies.map(async s => toggle.strategies.map(async (s) =>
this.featureService2.createStrategy( this.featureService2.createStrategy(
s, s,
createdFeature.project, createdFeature.project,
@ -248,7 +249,7 @@ class FeatureController extends Controller {
let strategies; let strategies;
if (updatedFeature.strategies) { if (updatedFeature.strategies) {
strategies = await Promise.all( strategies = await Promise.all(
updatedFeature.strategies.map(async s => updatedFeature.strategies.map(async (s) =>
this.featureService2.createStrategy( this.featureService2.createStrategy(
s, s,
projectId, projectId,

View File

@ -1,17 +1,15 @@
'use strict'; import supertest from 'supertest';
import { EventEmitter } from 'events';
const supertest = require('supertest'); import createStores from '../../../test/fixtures/store';
const { EventEmitter } = require('events'); import permissions from '../../../test/fixtures/permissions';
const store = require('../../../test/fixtures/store'); import getApp from '../../app';
const permissions = require('../../../test/fixtures/permissions'); import { createTestConfig } from '../../../test/config/test-config';
const getApp = require('../../app'); import { createServices } from '../../services';
const { createTestConfig } = require('../../../test/config/test-config');
const { createServices } = require('../../services');
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
function getSetup() { function getSetup() {
const stores = store.createStores(); const stores = createStores();
const perms = permissions(); const perms = permissions();
const config = createTestConfig({ const config = createTestConfig({
preRouterHook: perms.hook, preRouterHook: perms.hook,
@ -51,7 +49,7 @@ test('should return seen toggles even when there is nothing', () => {
return request return request
.get('/api/admin/metrics/seen-toggles') .get('/api/admin/metrics/seen-toggles')
.expect(200) .expect(200)
.expect(res => { .expect((res) => {
expect(res.body.length === 0).toBe(true); expect(res.body.length === 0).toBe(true);
}); });
}); });
@ -75,7 +73,7 @@ test('should return list of seen-toggles per app', () => {
return request return request
.get('/api/admin/metrics/seen-toggles') .get('/api/admin/metrics/seen-toggles')
.expect(200) .expect(200)
.expect(res => { .expect((res) => {
const seenAppsWithToggles = res.body; const seenAppsWithToggles = res.body;
expect(seenAppsWithToggles.length === 1).toBe(true); expect(seenAppsWithToggles.length === 1).toBe(true);
expect(seenAppsWithToggles[0].appName === appName).toBe(true); expect(seenAppsWithToggles[0].appName === appName).toBe(true);
@ -107,7 +105,7 @@ test('should return metrics for all toggles', () => {
return request return request
.get('/api/admin/metrics/feature-toggles') .get('/api/admin/metrics/feature-toggles')
.expect(200) .expect(200)
.expect(res => { .expect((res) => {
const metrics = res.body; const metrics = res.body;
expect(metrics.lastHour !== undefined).toBe(true); expect(metrics.lastHour !== undefined).toBe(true);
expect(metrics.lastMinute !== undefined).toBe(true); expect(metrics.lastMinute !== undefined).toBe(true);
@ -120,7 +118,7 @@ test('should return empty list of client applications', () => {
return request return request
.get('/api/admin/metrics/applications') .get('/api/admin/metrics/applications')
.expect(200) .expect(200)
.expect(res => { .expect((res) => {
expect(res.body.applications.length === 0).toBe(true); expect(res.body.applications.length === 0).toBe(true);
}); });
}); });
@ -134,7 +132,7 @@ test('should return applications', () => {
return request return request
.get('/api/admin/metrics/applications/') .get('/api/admin/metrics/applications/')
.expect(200) .expect(200)
.expect(res => { .expect((res) => {
const metrics = res.body; const metrics = res.body;
expect(metrics.applications.length === 1).toBe(true); expect(metrics.applications.length === 1).toBe(true);
expect(metrics.applications[0].appName === appName).toBe(true); expect(metrics.applications[0].appName === appName).toBe(true);

View File

@ -19,7 +19,7 @@ class MetricsController extends Controller {
}: Pick<IUnleashServices, 'clientMetricsService'>, }: Pick<IUnleashServices, 'clientMetricsService'>,
) { ) {
super(config); super(config);
this.logger = config.getLogger('/admin-api/metrics.js'); this.logger = config.getLogger('/admin-api/metrics.ts');
this.metrics = clientMetricsService; this.metrics = clientMetricsService;

View File

@ -5,7 +5,7 @@ import { IUnleashServices } from '../../../types/services';
import { Logger } from '../../../logger'; import { Logger } from '../../../logger';
import EnvironmentService from '../../../services/environment-service'; import EnvironmentService from '../../../services/environment-service';
import { handleErrors } from '../util'; import { handleErrors } from '../util';
import { UPDATE_FEATURE, UPDATE_PROJECT } from '../../../types/permissions'; import { UPDATE_PROJECT } from '../../../types/permissions';
const PREFIX = '/:projectId/environments'; const PREFIX = '/:projectId/environments';
@ -41,10 +41,10 @@ export default class EnvironmentsController extends Controller {
async addEnvironmentToProject( async addEnvironmentToProject(
req: Request< req: Request<
Omit<IProjectEnvironmentParams, 'environment'>, Omit<IProjectEnvironmentParams, 'environment'>,
any, any,
EnvironmentBody, EnvironmentBody,
any any
>, >,
res: Response, res: Response,
): Promise<void> { ): Promise<void> {

Some files were not shown because too many files have changed in this diff Show More