mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
fix: Stores as typescript and with interfaces. (#902)
Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com>
This commit is contained in:
parent
c5b8b2f920
commit
ff7be7696c
@ -8,3 +8,4 @@ website/translated_docs
|
||||
website/core
|
||||
website/pages
|
||||
websitev2
|
||||
setupJest.js
|
||||
|
12
.eslintrc
12
.eslintrc
@ -5,18 +5,17 @@
|
||||
},
|
||||
"extends": [
|
||||
"airbnb-typescript/base",
|
||||
"prettier"
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2019,
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"plugins": ["prettier","@typescript-eslint"],
|
||||
"plugins": ["@typescript-eslint","prettier"],
|
||||
"root": true,
|
||||
"rules": {
|
||||
"@typescript-eslint/no-var-requires": 0,
|
||||
"@typescript-eslint/indent": ["error", 4],
|
||||
"@typescript-eslint/naming-convention": 0,
|
||||
"@typescript-eslint/space-before-function-paren": 0,
|
||||
"import/prefer-default-export": 0,
|
||||
@ -46,10 +45,15 @@
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {
|
||||
"@typescript-eslint/explicit-module-boundary-types": ["error"],
|
||||
"@typescript-eslint/indent": ["error"],
|
||||
"@typescript-eslint/naming-convention": ["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/**"]
|
||||
|
28
package.json
28
package.json
@ -19,6 +19,7 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/unleash/unleash/issues"
|
||||
},
|
||||
"types": "./dist/lib/types/index.d.js",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
@ -43,6 +44,10 @@
|
||||
"clean": "del-cli --force dist"
|
||||
},
|
||||
"jest": {
|
||||
"automock": false,
|
||||
"setupFiles": [
|
||||
"./setupJest.js"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.tsx?$": "ts-jest"
|
||||
},
|
||||
@ -109,37 +114,42 @@
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/js-yaml": "^3.12.7",
|
||||
"@types/memoizee": "^0.4.6",
|
||||
"@types/node": "^15.6.0",
|
||||
"@types/node-fetch": "^2.5.10",
|
||||
"@types/nodemailer": "^6.4.1",
|
||||
"@types/owasp-password-strength-test": "^1.3.0",
|
||||
"@types/stoppable": "^1.1.1",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.22.0",
|
||||
"@typescript-eslint/parser": "^4.22.0",
|
||||
"copyfiles": "^2.4.1",
|
||||
"coveralls": "^3.1.0",
|
||||
"del-cli": "^4.0.1",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-airbnb-base": "^14.2.1",
|
||||
"eslint-config-airbnb-typescript": "^12.3.1",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-import": "^2.23.4",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"faker": "^5.5.3",
|
||||
"fetch-mock": "^9.11.0",
|
||||
"husky": "^4.2.3",
|
||||
"jest": "^27.0.1",
|
||||
"jest": "^27.0.6",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"lint-staged": "^11.0.0",
|
||||
"prettier": "^1.19.1",
|
||||
"prettier": "^2.3.2",
|
||||
"proxyquire": "^2.1.3",
|
||||
"source-map-support": "^0.5.19",
|
||||
"superagent": "^6.1.0",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^27.0.0",
|
||||
"ts-node": "^10.0.0",
|
||||
"ts-jest": "^27.0.4",
|
||||
"ts-node": "^10.1.0",
|
||||
"tsc-watch": "^4.4.0",
|
||||
"typescript": "^4.2.4"
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"set-value": "^2.0.1",
|
||||
|
2
setupJest.js
Normal file
2
setupJest.js
Normal 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();
|
11
snapshots/dist/lib/addons/slack.test.js.md
vendored
11
snapshots/dist/lib/addons/slack.test.js.md
vendored
@ -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"}]}]}'
|
@ -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"}'
|
@ -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"}]}]}'
|
@ -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"}]}]}'
|
@ -1,8 +1,8 @@
|
||||
const joi = require('joi');
|
||||
const { nameType } = require('../routes/admin-api/util');
|
||||
const { tagTypeSchema } = require('../services/tag-type-schema');
|
||||
import joi from 'joi';
|
||||
import { nameType } from '../routes/admin-api/util';
|
||||
import { tagTypeSchema } from '../services/tag-type-schema';
|
||||
|
||||
const addonDefinitionSchema = joi.object().keys({
|
||||
export const addonDefinitionSchema = joi.object().keys({
|
||||
name: nameType,
|
||||
displayName: joi.string(),
|
||||
documentationUrl: joi.string().uri({ scheme: [/https?/] }),
|
||||
@ -21,16 +21,6 @@ const addonDefinitionSchema = joi.object().keys({
|
||||
sensitive: joi.boolean().default(false),
|
||||
}),
|
||||
),
|
||||
events: joi
|
||||
.array()
|
||||
.optional()
|
||||
.items(joi.string()),
|
||||
tagTypes: joi
|
||||
.array()
|
||||
.optional()
|
||||
.items(tagTypeSchema),
|
||||
events: joi.array().optional().items(joi.string()),
|
||||
tagTypes: joi.array().optional().items(tagTypeSchema),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
addonDefinitionSchema,
|
||||
};
|
@ -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();
|
||||
});
|
40
src/lib/addons/addon.test.ts
Normal file
40
src/lib/addons/addon.test.ts
Normal 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();
|
||||
});
|
@ -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');
|
||||
const { addonDefinitionSchema } = require('./addon-schema');
|
||||
export default abstract class Addon {
|
||||
logger: Logger;
|
||||
|
||||
class Addon {
|
||||
constructor(definition, { getLogger }) {
|
||||
_name: string;
|
||||
|
||||
_definition: IAddonDefinition;
|
||||
|
||||
constructor(
|
||||
definition: IAddonDefinition,
|
||||
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
|
||||
) {
|
||||
this.logger = getLogger(`addon/${definition.name}`);
|
||||
const { error } = addonDefinitionSchema.validate(definition);
|
||||
if (error) {
|
||||
@ -18,15 +28,20 @@ class Addon {
|
||||
this._definition = definition;
|
||||
}
|
||||
|
||||
get name() {
|
||||
get name(): string {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
get definition() {
|
||||
get definition(): IAddonDefinition {
|
||||
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];
|
||||
let res;
|
||||
try {
|
||||
@ -45,6 +60,7 @@ class Addon {
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Addon;
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
abstract handleEvent(event: IEvent, parameters: any): Promise<void>;
|
||||
}
|
@ -1,15 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
import {
|
||||
FEATURE_CREATED,
|
||||
FEATURE_UPDATED,
|
||||
FEATURE_ARCHIVED,
|
||||
FEATURE_REVIVED,
|
||||
FEATURE_STALE_ON,
|
||||
FEATURE_STALE_OFF,
|
||||
} = require('../types/events');
|
||||
} from '../types/events';
|
||||
import { IAddonDefinition } from '../types/model';
|
||||
|
||||
module.exports = {
|
||||
const dataDogDefinition: IAddonDefinition = {
|
||||
name: 'datadog',
|
||||
displayName: '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.',
|
||||
type: 'url',
|
||||
required: false,
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
@ -50,3 +50,5 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default dataDogDefinition;
|
@ -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(
|
||||
'./addon',
|
||||
() =>
|
||||
class Addon {
|
||||
logger: Logger;
|
||||
|
||||
constructor(definition, { getLogger }) {
|
||||
this.logger = getLogger('addon/test');
|
||||
this.fetchRetryCalls = [];
|
||||
fetchRetryCalls = [];
|
||||
}
|
||||
|
||||
async fetchRetry(url, options, retries, backoff) {
|
||||
this.fetchRetryCalls.push({ url, options, retries, backoff });
|
||||
fetchRetryCalls.push({
|
||||
url,
|
||||
options,
|
||||
retries,
|
||||
backoff,
|
||||
});
|
||||
return Promise.resolve({ status: 200 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const DatadogAddon = require('./datadog');
|
||||
|
||||
const noLogger = require('../../test/fixtures/no-logger');
|
||||
|
||||
test('Should call datadog webhook', async () => {
|
||||
const addon = new DatadogAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const event = {
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -40,9 +53,9 @@ test('Should call datadog webhook', async () => {
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
expect(addon.fetchRetryCalls.length).toBe(1);
|
||||
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Should call datadog webhook for archived toggle', async () => {
|
||||
@ -50,7 +63,9 @@ test('Should call datadog webhook for archived toggle', async () => {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const event = {
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_ARCHIVED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -63,7 +78,7 @@ test('Should call datadog webhook for archived toggle', async () => {
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
expect(addon.fetchRetryCalls.length).toBe(1);
|
||||
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
@ -1,30 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
const YAML = require('js-yaml');
|
||||
const Addon = require('./addon');
|
||||
|
||||
const {
|
||||
import YAML from 'js-yaml';
|
||||
import Addon from './addon';
|
||||
import {
|
||||
FEATURE_CREATED,
|
||||
FEATURE_UPDATED,
|
||||
FEATURE_ARCHIVED,
|
||||
FEATURE_REVIVED,
|
||||
FEATURE_STALE_ON,
|
||||
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 {
|
||||
constructor(args) {
|
||||
super(definition, args);
|
||||
this.unleashUrl = args.unleashUrl;
|
||||
export default class DatadogAddon extends Addon {
|
||||
unleashUrl: string;
|
||||
|
||||
constructor(config: { unleashUrl: string; getLogger: LogProvider }) {
|
||||
super(definition, config);
|
||||
this.unleashUrl = config.unleashUrl;
|
||||
}
|
||||
|
||||
async handleEvent(event, parameters) {
|
||||
const {
|
||||
url = 'https://api.datadoghq.com/api/v1/events',
|
||||
apiKey,
|
||||
} = parameters;
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
async handleEvent(event: IEvent, parameters: any): Promise<void> {
|
||||
const { url = 'https://api.datadoghq.com/api/v1/events', apiKey } =
|
||||
parameters;
|
||||
let text;
|
||||
|
||||
if ([FEATURE_ARCHIVED, FEATURE_REVIVED].includes(event.type)) {
|
||||
@ -37,7 +37,7 @@ class DatadogAddon extends Addon {
|
||||
|
||||
const { tags: eventTags } = event;
|
||||
const tags =
|
||||
eventTags && eventTags.map(tag => `${tag.value}:${tag.type}`);
|
||||
eventTags && eventTags.map((tag) => `${tag.value}:${tag.type}`);
|
||||
const body = {
|
||||
text: `%%% \n ${text} \n %%% `,
|
||||
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';
|
||||
return `${this.unleashUrl}/${path}/strategies/${event.data.name}`;
|
||||
}
|
||||
|
||||
generateStaleText(event) {
|
||||
generateStaleText(event: IEvent): string {
|
||||
const { createdBy, data, type } = event;
|
||||
const isStale = type === FEATURE_STALE_ON;
|
||||
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}.`;
|
||||
}
|
||||
|
||||
generateArchivedText(event) {
|
||||
generateArchivedText(event: IEvent): string {
|
||||
const { createdBy, data, type } = event;
|
||||
const action = type === FEATURE_ARCHIVED ? 'archived' : 'revived';
|
||||
const feature = `[${data.name}](${this.featureLink(event)})`;
|
||||
return `The feature toggle *${feature}* was *${action}* by ${createdBy}.`;
|
||||
}
|
||||
|
||||
generateText(event) {
|
||||
generateText(event: IEvent): string {
|
||||
const { createdBy, data, type } = event;
|
||||
const action = this.getAction(type);
|
||||
const feature = `[${data.name}](${this.featureLink(event)})`;
|
||||
@ -90,7 +90,7 @@ This was changed by ${createdBy}.`;
|
||||
const stale = data.stale ? '("stale")' : '';
|
||||
const typeStr = `**Type**: ${data.type}`;
|
||||
const project = `**Project**: ${data.project}`;
|
||||
const strategies = `**Activation strategies**: \`\`\`${YAML.safeDump(
|
||||
const strategies = `**Activation strategies**: \`\`\`${YAML.dump(
|
||||
data.strategies,
|
||||
{ skipInvalid: true },
|
||||
)}\`\`\``;
|
||||
@ -99,7 +99,7 @@ ${enabled}${stale} | ${typeStr} | ${project}
|
||||
${strategies}`;
|
||||
}
|
||||
|
||||
getAction(type) {
|
||||
getAction(type: string): string {
|
||||
switch (type) {
|
||||
case FEATURE_CREATED:
|
||||
return 'created';
|
||||
@ -110,5 +110,3 @@ ${strategies}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DatadogAddon;
|
@ -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
27
src/lib/addons/index.ts
Normal 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;
|
||||
}, {});
|
||||
};
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
@ -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;
|
@ -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);
|
||||
});
|
@ -1,15 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
import {
|
||||
FEATURE_CREATED,
|
||||
FEATURE_UPDATED,
|
||||
FEATURE_ARCHIVED,
|
||||
FEATURE_REVIVED,
|
||||
FEATURE_STALE_ON,
|
||||
FEATURE_STALE_OFF,
|
||||
} = require('../types/events');
|
||||
} from '../types/events';
|
||||
import { IAddonDefinition } from '../types/model';
|
||||
|
||||
module.exports = {
|
||||
const slackDefinition: IAddonDefinition = {
|
||||
name: 'slack',
|
||||
displayName: '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".',
|
||||
type: 'text',
|
||||
required: false,
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
name: 'emojiIcon',
|
||||
@ -39,6 +39,7 @@ module.exports = {
|
||||
'The emoji_icon to use when posting messages to slack. Defaults to ":unleash:".',
|
||||
type: 'text',
|
||||
required: false,
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
name: 'defaultChannel',
|
||||
@ -47,6 +48,7 @@ module.exports = {
|
||||
'Default channel to post updates to if not specified in the slack-tag',
|
||||
type: 'text',
|
||||
required: true,
|
||||
sensitive: false,
|
||||
},
|
||||
],
|
||||
events: [
|
||||
@ -66,3 +68,5 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default slackDefinition;
|
@ -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(
|
||||
'./addon',
|
||||
() =>
|
||||
class Addon {
|
||||
logger: Logger;
|
||||
|
||||
constructor(definition, { getLogger }) {
|
||||
this.logger = getLogger('addon/test');
|
||||
this.fetchRetryCalls = [];
|
||||
fetchRetryCalls = [];
|
||||
}
|
||||
|
||||
async fetchRetry(url, options, retries, backoff) {
|
||||
this.fetchRetryCalls.push({ url, options, retries, backoff });
|
||||
fetchRetryCalls.push({
|
||||
url,
|
||||
options,
|
||||
retries,
|
||||
backoff,
|
||||
});
|
||||
return Promise.resolve({ status: 200 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const SlackAddon = require('./slack');
|
||||
|
||||
const noLogger = require('../../test/fixtures/no-logger');
|
||||
|
||||
test('Should call slack webhook', async () => {
|
||||
const addon = new SlackAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const event = {
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -40,9 +53,9 @@ test('Should call slack webhook', async () => {
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
expect(addon.fetchRetryCalls.length).toBe(1);
|
||||
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Should call slack webhook for archived toggle', async () => {
|
||||
@ -50,7 +63,9 @@ test('Should call slack webhook for archived toggle', async () => {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const event = {
|
||||
const event: IEvent = {
|
||||
id: 2,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_ARCHIVED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -63,9 +78,9 @@ test('Should call slack webhook for archived toggle', async () => {
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
expect(addon.fetchRetryCalls.length).toBe(1);
|
||||
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Should use default channel', async () => {
|
||||
@ -73,7 +88,9 @@ test('Should use default channel', async () => {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const event = {
|
||||
const event: IEvent = {
|
||||
id: 3,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -90,7 +107,7 @@ test('Should use default channel', async () => {
|
||||
|
||||
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');
|
||||
});
|
||||
@ -100,7 +117,9 @@ test('Should override default channel with data from tag', async () => {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const event = {
|
||||
const event: IEvent = {
|
||||
id: 4,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -123,7 +142,7 @@ test('Should override default channel with data from tag', async () => {
|
||||
|
||||
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');
|
||||
});
|
||||
@ -133,7 +152,9 @@ test('Should post to all channels in tags', async () => {
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const event = {
|
||||
const event: IEvent = {
|
||||
id: 5,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -160,10 +181,10 @@ test('Should post to all channels in tags', async () => {
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
|
||||
const req1 = JSON.parse(addon.fetchRetryCalls[0].options.body);
|
||||
const req2 = JSON.parse(addon.fetchRetryCalls[1].options.body);
|
||||
const req1 = JSON.parse(fetchRetryCalls[0].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(req2.channel).toBe('#another-channel-2');
|
||||
});
|
@ -1,7 +1,8 @@
|
||||
'use strict';
|
||||
import YAML from 'js-yaml';
|
||||
import Addon from './addon';
|
||||
|
||||
const YAML = require('js-yaml');
|
||||
const Addon = require('./addon');
|
||||
import slackDefinition from './slack-definition';
|
||||
import { IAddonConfig, IEvent } from '../types/model';
|
||||
|
||||
const {
|
||||
FEATURE_CREATED,
|
||||
@ -12,15 +13,16 @@ const {
|
||||
FEATURE_STALE_OFF,
|
||||
} = require('../types/events');
|
||||
|
||||
const definition = require('./slack-definition');
|
||||
export default class SlackAddon extends Addon {
|
||||
unleashUrl: string;
|
||||
|
||||
class SlackAddon extends Addon {
|
||||
constructor(args) {
|
||||
super(definition, args);
|
||||
constructor(args: IAddonConfig) {
|
||||
super(slackDefinition, args);
|
||||
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,
|
||||
defaultChannel,
|
||||
@ -44,7 +46,7 @@ class SlackAddon extends Addon {
|
||||
text = this.generateText(event);
|
||||
}
|
||||
|
||||
const requests = slackChannels.map(channel => {
|
||||
const requests = slackChannels.map((channel) => {
|
||||
const body = {
|
||||
username,
|
||||
icon_emoji: iconEmoji, // eslint-disable-line camelcase
|
||||
@ -76,20 +78,25 @@ class SlackAddon extends Addon {
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
featureLink(event) {
|
||||
featureLink(event: IEvent): string {
|
||||
const path = event.type === FEATURE_ARCHIVED ? 'archive' : 'features';
|
||||
return `${this.unleashUrl}/${path}/strategies/${event.data.name}`;
|
||||
}
|
||||
|
||||
findSlackChannels({ tags = [] }) {
|
||||
return tags.filter(tag => tag.type === 'slack').map(t => t.value);
|
||||
findSlackChannels({ tags }: Pick<IEvent, 'tags'>): string[] {
|
||||
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 isStale = type === FEATURE_STALE_ON;
|
||||
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}.`;
|
||||
}
|
||||
|
||||
generateArchivedText(event) {
|
||||
generateArchivedText(event: IEvent): string {
|
||||
const { createdBy, data, type } = event;
|
||||
const action = type === FEATURE_ARCHIVED ? 'archived' : 'revived';
|
||||
const feature = `<${this.featureLink(event)}|${data.name}>`;
|
||||
return `The feature toggle *${feature}* was *${action}* by ${createdBy}.`;
|
||||
}
|
||||
|
||||
generateText(event) {
|
||||
generateText(event: IEvent): string {
|
||||
const { createdBy, data, type } = event;
|
||||
const action = this.getAction(type);
|
||||
const feature = `<${this.featureLink(event)}|${data.name}>`;
|
||||
@ -125,7 +132,7 @@ ${enabled}${stale} | ${typeStr} | ${project}
|
||||
${strategies}`;
|
||||
}
|
||||
|
||||
getAction(type) {
|
||||
getAction(type: string): string {
|
||||
switch (type) {
|
||||
case FEATURE_CREATED:
|
||||
return 'created';
|
@ -1,15 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
import {
|
||||
FEATURE_CREATED,
|
||||
FEATURE_UPDATED,
|
||||
FEATURE_ARCHIVED,
|
||||
FEATURE_REVIVED,
|
||||
FEATURE_STALE_ON,
|
||||
FEATURE_STALE_OFF,
|
||||
} = require('../types/events');
|
||||
} from '../types/events';
|
||||
import { IAddonDefinition } from '../types/model';
|
||||
|
||||
module.exports = {
|
||||
const teamsDefinition: IAddonDefinition = {
|
||||
name: 'teams',
|
||||
displayName: 'Microsoft Teams',
|
||||
description: 'Allows Unleash to post updates to Microsoft Teams.',
|
||||
@ -32,3 +31,5 @@ module.exports = {
|
||||
FEATURE_STALE_OFF,
|
||||
],
|
||||
};
|
||||
|
||||
export default teamsDefinition;
|
@ -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(
|
||||
'./addon',
|
||||
() =>
|
||||
class Addon {
|
||||
logger: Logger;
|
||||
|
||||
constructor(definition, { getLogger }) {
|
||||
this.logger = getLogger('addon/test');
|
||||
this.fetchRetryCalls = [];
|
||||
fetchRetryCalls = [];
|
||||
}
|
||||
|
||||
async fetchRetry(url, options, retries, backoff) {
|
||||
this.fetchRetryCalls.push({ url, options, retries, backoff });
|
||||
fetchRetryCalls.push({
|
||||
url,
|
||||
options,
|
||||
retries,
|
||||
backoff,
|
||||
});
|
||||
return Promise.resolve({ status: 200 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const TeamsAddon = require('./teams');
|
||||
|
||||
const noLogger = require('../../test/fixtures/no-logger');
|
||||
|
||||
test('Should call teams webhook', async () => {
|
||||
const addon = new TeamsAddon({
|
||||
getLogger: noLogger,
|
||||
unleashUrl: 'http://some-url.com',
|
||||
});
|
||||
const event = {
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -40,9 +54,9 @@ test('Should call teams webhook', async () => {
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
expect(addon.fetchRetryCalls.length).toBe(1);
|
||||
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
const event = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_ARCHIVED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -63,7 +79,7 @@ test('Should call teams webhook for archived toggle', async () => {
|
||||
};
|
||||
|
||||
await addon.handleEvent(event, parameters);
|
||||
expect(addon.fetchRetryCalls.length).toBe(1);
|
||||
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(addon.fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
|
||||
});
|
@ -1,26 +1,29 @@
|
||||
'use strict';
|
||||
import YAML from 'js-yaml';
|
||||
import Addon from './addon';
|
||||
|
||||
const YAML = require('js-yaml');
|
||||
const Addon = require('./addon');
|
||||
|
||||
const {
|
||||
import {
|
||||
FEATURE_CREATED,
|
||||
FEATURE_UPDATED,
|
||||
FEATURE_ARCHIVED,
|
||||
FEATURE_REVIVED,
|
||||
FEATURE_STALE_ON,
|
||||
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 {
|
||||
constructor(args) {
|
||||
super(definition, args);
|
||||
export default class TeamsAddon extends Addon {
|
||||
unleashUrl: string;
|
||||
|
||||
constructor(args: { unleashUrl: string; getLogger: LogProvider }) {
|
||||
super(teamsDefinition, args);
|
||||
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 { createdBy, data, type } = event;
|
||||
let text = '';
|
||||
@ -82,12 +85,12 @@ class TeamsAddon extends Addon {
|
||||
);
|
||||
}
|
||||
|
||||
featureLink(event) {
|
||||
featureLink(event: IEvent): string {
|
||||
const path = event.type === FEATURE_ARCHIVED ? 'archive' : 'features';
|
||||
return `${this.unleashUrl}/${path}/strategies/${event.data.name}`;
|
||||
}
|
||||
|
||||
generateStaleText(event) {
|
||||
generateStaleText(event: IEvent): string {
|
||||
const { data, type } = event;
|
||||
const isStale = type === FEATURE_STALE_ON;
|
||||
if (isStale) {
|
||||
@ -96,13 +99,13 @@ class TeamsAddon extends Addon {
|
||||
return `The feature toggle *${data.name}* was *unmarked* as stale`;
|
||||
}
|
||||
|
||||
generateArchivedText(event) {
|
||||
generateArchivedText(event: IEvent): string {
|
||||
const { data, type } = event;
|
||||
const action = type === FEATURE_ARCHIVED ? 'archived' : 'revived';
|
||||
return `The feature toggle *${data.name}* was *${action}*`;
|
||||
}
|
||||
|
||||
generateText(event) {
|
||||
generateText(event: IEvent): string {
|
||||
const { data } = event;
|
||||
const typeStr = `*Type*: ${data.type}`;
|
||||
const project = `*Project*: ${data.project}`;
|
||||
@ -113,7 +116,7 @@ class TeamsAddon extends Addon {
|
||||
return `Feature toggle ${data.name} | ${typeStr} | ${project} <br /> ${strategies}`;
|
||||
}
|
||||
|
||||
getAction(type) {
|
||||
getAction(type: string): string {
|
||||
switch (type) {
|
||||
case FEATURE_CREATED:
|
||||
return 'Create';
|
||||
@ -124,5 +127,3 @@ class TeamsAddon extends Addon {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TeamsAddon;
|
@ -1,13 +1,14 @@
|
||||
const {
|
||||
FEATURE_CREATED,
|
||||
FEATURE_UPDATED,
|
||||
import {
|
||||
FEATURE_ARCHIVED,
|
||||
FEATURE_CREATED,
|
||||
FEATURE_REVIVED,
|
||||
FEATURE_STALE_ON,
|
||||
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',
|
||||
displayName: 'Webhook',
|
||||
description:
|
||||
@ -31,6 +32,7 @@ module.exports = {
|
||||
'(Optional) The Content-Type header to use. Defaults to "application/json".',
|
||||
type: 'text',
|
||||
required: false,
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
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)",
|
||||
type: 'textfield',
|
||||
required: false,
|
||||
sensitive: false,
|
||||
},
|
||||
],
|
||||
events: [
|
||||
@ -56,3 +59,5 @@ module.exports = {
|
||||
FEATURE_STALE_OFF,
|
||||
],
|
||||
};
|
||||
|
||||
export default webhookDefinition;
|
@ -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(
|
||||
'./addon',
|
||||
() =>
|
||||
class Addon {
|
||||
logger: Logger;
|
||||
|
||||
constructor(definition, { getLogger }) {
|
||||
this.logger = getLogger('addon/test');
|
||||
this.fetchRetryCalls = [];
|
||||
fetchRetryCalls = [];
|
||||
}
|
||||
|
||||
async fetchRetry(url, options, retries, backoff) {
|
||||
this.fetchRetryCalls.push({ url, options, retries, backoff });
|
||||
fetchRetryCalls.push({
|
||||
url,
|
||||
options,
|
||||
retries,
|
||||
backoff,
|
||||
});
|
||||
return Promise.resolve({ status: 200 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const WebhookAddon = require('./webhook');
|
||||
|
||||
const noLogger = require('../../test/fixtures/no-logger');
|
||||
|
||||
test('Should handle event without "bodyTemplate"', () => {
|
||||
const addon = new WebhookAddon({ getLogger: noLogger });
|
||||
const event = {
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -37,14 +51,16 @@ test('Should handle event without "bodyTemplate"', () => {
|
||||
};
|
||||
|
||||
addon.handleEvent(event, parameters);
|
||||
expect(addon.fetchRetryCalls.length).toBe(1);
|
||||
expect(addon.fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(addon.fetchRetryCalls[0].options.body).toBe(JSON.stringify(event));
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(fetchRetryCalls[0].url).toBe(parameters.url);
|
||||
expect(fetchRetryCalls[0].options.body).toBe(JSON.stringify(event));
|
||||
});
|
||||
|
||||
test('Should format event with "bodyTemplate"', () => {
|
||||
const addon = new WebhookAddon({ getLogger: noLogger });
|
||||
const event = {
|
||||
const event: IEvent = {
|
||||
id: 1,
|
||||
createdAt: new Date(),
|
||||
type: FEATURE_CREATED,
|
||||
createdBy: 'some@user.com',
|
||||
data: {
|
||||
@ -61,8 +77,8 @@ test('Should format event with "bodyTemplate"', () => {
|
||||
};
|
||||
|
||||
addon.handleEvent(event, parameters);
|
||||
const call = addon.fetchRetryCalls[0];
|
||||
expect(addon.fetchRetryCalls.length).toBe(1);
|
||||
const call = fetchRetryCalls[0];
|
||||
expect(fetchRetryCalls.length).toBe(1);
|
||||
expect(call.url).toBe(parameters.url);
|
||||
expect(call.options.headers['Content-Type']).toBe('text/plain');
|
||||
expect(call.options.body).toBe('feature-created on toggle some-toggle');
|
@ -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');
|
||||
const Addon = require('./addon');
|
||||
const definition = require('./webhook-definition');
|
||||
|
||||
class Webhook extends Addon {
|
||||
constructor(args) {
|
||||
export default class Webhook extends Addon {
|
||||
constructor(args: { getLogger: LogProvider }) {
|
||||
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 context = {
|
||||
event,
|
||||
@ -35,5 +36,3 @@ class Webhook extends Addon {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Webhook;
|
@ -1,7 +1,7 @@
|
||||
import { publicFolder } from 'unleash-frontend';
|
||||
import fs from 'fs';
|
||||
import EventEmitter from 'events';
|
||||
import express, { Application } from 'express';
|
||||
import express, { Application, RequestHandler } from 'express';
|
||||
import cors from 'cors';
|
||||
import compression from 'compression';
|
||||
import favicon from 'serve-favicon';
|
||||
@ -14,7 +14,6 @@ import apiTokenMiddleware from './middleware/api-token-middleware';
|
||||
import { IUnleashServices } from './types/services';
|
||||
import { IAuthType, IUnleashConfig } from './types/option';
|
||||
import { IUnleashStores } from './types/stores';
|
||||
import unleashDbSession from './middleware/session-db';
|
||||
|
||||
import IndexRouter from './routes';
|
||||
|
||||
@ -23,6 +22,7 @@ import demoAuthentication from './middleware/demo-authentication';
|
||||
import ossAuthentication from './middleware/oss-authentication';
|
||||
import noAuthentication from './middleware/no-authentication';
|
||||
import secureHeaders from './middleware/secure-headers';
|
||||
|
||||
import { rewriteHTML } from './util/rewriteHTML';
|
||||
|
||||
export default function getApp(
|
||||
@ -30,6 +30,7 @@ export default function getApp(
|
||||
stores: IUnleashStores,
|
||||
services: IUnleashServices,
|
||||
eventBus?: EventEmitter,
|
||||
unleashSession?: RequestHandler,
|
||||
): Application {
|
||||
const app = express();
|
||||
|
||||
@ -63,7 +64,9 @@ export default function getApp(
|
||||
app.use(compression());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ strict: false }));
|
||||
app.use(unleashDbSession(config, stores));
|
||||
if (unleashSession) {
|
||||
app.use(unleashSession);
|
||||
}
|
||||
app.use(secureHeaders(config));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(favicon(path.join(publicFolder, 'favicon.ico')));
|
||||
@ -76,7 +79,7 @@ export default function getApp(
|
||||
switch (config.authentication.type) {
|
||||
case IAuthType.OPEN_SOURCE: {
|
||||
app.use(baseUriPath, apiTokenMiddleware(config, services));
|
||||
ossAuthentication(app, config);
|
||||
ossAuthentication(app, config.server.baseUriPath);
|
||||
break;
|
||||
}
|
||||
case IAuthType.ENTERPRISE: {
|
||||
|
@ -48,7 +48,7 @@ function safeBoolean(envVar, defaultVal) {
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
@ -2,6 +2,13 @@ import { EventEmitter } from 'events';
|
||||
import { Knex } from 'knex';
|
||||
import metricsHelper from '../util/metrics-helper';
|
||||
import { DB_TIME } from '../metric-events';
|
||||
import { Logger } from '../logger';
|
||||
import {
|
||||
IAccessStore,
|
||||
IRole,
|
||||
IUserPermission,
|
||||
IUserRole,
|
||||
} from '../types/stores/access-store';
|
||||
|
||||
const T = {
|
||||
ROLE_USER: 'role_user',
|
||||
@ -9,26 +16,8 @@ const T = {
|
||||
ROLE_PERMISSION: 'role_permission',
|
||||
};
|
||||
|
||||
export interface IUserPermission {
|
||||
project?: string;
|
||||
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;
|
||||
export class AccessStore implements IAccessStore {
|
||||
private logger: Logger;
|
||||
|
||||
private timer: Function;
|
||||
|
||||
@ -36,7 +25,7 @@ export class AccessStore {
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: Function) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('access-store.js');
|
||||
this.logger = getLogger('access-store.ts');
|
||||
this.timer = (action: string) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
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[]> {
|
||||
const stopTimer = this.timer('getPermissionsForUser');
|
||||
const rows = await this.db
|
||||
@ -110,12 +130,12 @@ export class AccessStore {
|
||||
.where('ru.user_id', '=', userId);
|
||||
}
|
||||
|
||||
async getUserIdsForRole(roleId: number): Promise<IRole[]> {
|
||||
async getUserIdsForRole(roleId: number): Promise<number[]> {
|
||||
const rows = await this.db
|
||||
.select(['user_id'])
|
||||
.from<IRole>(T.ROLE_USER)
|
||||
.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> {
|
||||
@ -155,9 +175,20 @@ export class AccessStore {
|
||||
description?: string,
|
||||
): Promise<IRole> {
|
||||
const [id] = await this.db(T.ROLES)
|
||||
.insert({ name, description, type, project })
|
||||
.insert({
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
project,
|
||||
})
|
||||
.returning('id');
|
||||
return { id, name, description, type, project };
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
project,
|
||||
};
|
||||
}
|
||||
|
||||
async addPermissionsToRole(
|
||||
@ -165,7 +196,7 @@ export class AccessStore {
|
||||
permissions: string[],
|
||||
projectId?: string,
|
||||
): Promise<void> {
|
||||
const rows = permissions.map(permission => ({
|
||||
const rows = permissions.map((permission) => ({
|
||||
role_id,
|
||||
project: projectId,
|
||||
permission,
|
||||
@ -195,7 +226,7 @@ export class AccessStore {
|
||||
.leftJoin(`${T.ROLE_USER} AS ru`, 'r.id', 'ru.role_id')
|
||||
.where('r.type', '=', 'root');
|
||||
|
||||
return rows.map(row => ({
|
||||
return rows.map((row) => ({
|
||||
roleId: +row.id,
|
||||
userId: +row.user_id,
|
||||
}));
|
||||
|
@ -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
132
src/lib/db/addon-store.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
@ -4,6 +4,12 @@ import metricsHelper from '../util/metrics-helper';
|
||||
import { DB_TIME } from '../metric-events';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import {
|
||||
ApiTokenType,
|
||||
IApiToken,
|
||||
IApiTokenCreate,
|
||||
IApiTokenStore,
|
||||
} from '../types/stores/api-token-store';
|
||||
|
||||
const TABLE = 'api_tokens';
|
||||
|
||||
@ -17,23 +23,6 @@ interface ITokenTable {
|
||||
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) => ({
|
||||
username: newToken.username,
|
||||
secret: newToken.secret,
|
||||
@ -49,7 +38,7 @@ const toToken = (row: ITokenTable): IApiToken => ({
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
|
||||
export class ApiTokenStore {
|
||||
export class ApiTokenStore implements IApiTokenStore {
|
||||
private logger: Logger;
|
||||
|
||||
private timer: Function;
|
||||
@ -90,10 +79,24 @@ export class ApiTokenStore {
|
||||
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> {
|
||||
return this.db<ITokenTable>(TABLE)
|
||||
.where({ secret })
|
||||
.del();
|
||||
return this.db<ITokenTable>(TABLE).where({ secret }).del();
|
||||
}
|
||||
|
||||
async deleteAll(): Promise<void> {
|
||||
|
@ -1,6 +1,12 @@
|
||||
/* eslint camelcase:off */
|
||||
|
||||
const NotFoundError = require('../error/notfound-error');
|
||||
import EventEmitter from 'events';
|
||||
import { Knex } from 'knex';
|
||||
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 = [
|
||||
'app_name',
|
||||
@ -15,7 +21,7 @@ const COLUMNS = [
|
||||
];
|
||||
const TABLE = 'client_applications';
|
||||
|
||||
const mapRow = row => ({
|
||||
const mapRow: (any) => IClientApplication = (row) => ({
|
||||
appName: row.app_name,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@ -25,9 +31,11 @@ const mapRow = row => ({
|
||||
url: row.url,
|
||||
color: row.color,
|
||||
icon: row.icon,
|
||||
lastSeen: row.last_seen,
|
||||
announced: row.announced,
|
||||
});
|
||||
|
||||
const remapRow = input => {
|
||||
const remapRow = (input) => {
|
||||
const temp = {
|
||||
app_name: input.appName,
|
||||
updated_at: input.updatedAt || new Date(),
|
||||
@ -40,7 +48,7 @@ const remapRow = input => {
|
||||
icon: input.icon,
|
||||
strategies: JSON.stringify(input.strategies),
|
||||
};
|
||||
Object.keys(temp).forEach(k => {
|
||||
Object.keys(temp).forEach((k) => {
|
||||
if (temp[k] === undefined) {
|
||||
// not using !temp[k] to allow false and null values to get through
|
||||
delete temp[k];
|
||||
@ -49,38 +57,38 @@ const remapRow = input => {
|
||||
return temp;
|
||||
};
|
||||
|
||||
class ClientApplicationsDb {
|
||||
constructor(db, eventBus) {
|
||||
export default class ClientApplicationsStore
|
||||
implements IClientApplicationsStore
|
||||
{
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
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);
|
||||
return this.db(TABLE)
|
||||
.insert(row)
|
||||
.onConflict('app_name')
|
||||
.merge();
|
||||
await this.db(TABLE).insert(row).onConflict('app_name').merge();
|
||||
}
|
||||
|
||||
async bulkUpsert(apps) {
|
||||
async bulkUpsert(apps: Partial<IClientApplication>[]): Promise<void> {
|
||||
const rows = apps.map(remapRow);
|
||||
return this.db(TABLE)
|
||||
.insert(rows)
|
||||
.onConflict('app_name')
|
||||
.merge();
|
||||
await this.db(TABLE).insert(rows).onConflict('app_name').merge();
|
||||
}
|
||||
|
||||
async exists({ appName }) {
|
||||
async exists(appName: string): Promise<boolean> {
|
||||
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],
|
||||
);
|
||||
const { present } = result.rows[0];
|
||||
return present;
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
async getAll(): Promise<IClientApplication[]> {
|
||||
const rows = await this.db
|
||||
.select(COLUMNS)
|
||||
.from(TABLE)
|
||||
@ -89,7 +97,7 @@ class ClientApplicationsDb {
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
|
||||
async getApplication(appName) {
|
||||
async getApplication(appName: string): Promise<IClientApplication> {
|
||||
const row = await this.db
|
||||
.select(COLUMNS)
|
||||
.where('app_name', appName)
|
||||
@ -103,10 +111,8 @@ class ClientApplicationsDb {
|
||||
return mapRow(row);
|
||||
}
|
||||
|
||||
async deleteApplication(appName) {
|
||||
return this.db(TABLE)
|
||||
.where('app_name', appName)
|
||||
.del();
|
||||
async deleteApplication(appName: string): Promise<void> {
|
||||
return this.db(TABLE).where('app_name', appName).del();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -118,23 +124,21 @@ class ClientApplicationsDb {
|
||||
* ) as foo
|
||||
* WHERE foo.strategyName = '"other"';
|
||||
*/
|
||||
async getAppsForStrategy(strategyName) {
|
||||
async getAppsForStrategy(
|
||||
query: IApplicationQuery,
|
||||
): Promise<IClientApplication[]> {
|
||||
const rows = await this.db.select(COLUMNS).from(TABLE);
|
||||
const apps = rows.map(mapRow);
|
||||
|
||||
return rows
|
||||
.map(mapRow)
|
||||
.filter(apps =>
|
||||
apps.filter(app => app.strategies.includes(strategyName)),
|
||||
if (query.strategyName) {
|
||||
return apps.filter((app) =>
|
||||
app.strategies.includes(query.strategyName),
|
||||
);
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
|
||||
async getApplications(filter) {
|
||||
return filter && filter.strategyName
|
||||
? this.getAppsForStrategy(filter.strategyName)
|
||||
: this.getAll();
|
||||
}
|
||||
|
||||
async getUnannounced() {
|
||||
async getUnannounced(): Promise<IClientApplication[]> {
|
||||
const rows = await this.db(TABLE)
|
||||
.select(COLUMNS)
|
||||
.where('announced', false);
|
||||
@ -145,7 +149,7 @@ class ClientApplicationsDb {
|
||||
* Updates all rows that have announced = false to announced =true and returns the rows altered
|
||||
* @return {[app]} - Apps that hadn't been announced
|
||||
*/
|
||||
async setUnannouncedToAnnounced() {
|
||||
async setUnannouncedToAnnounced(): Promise<IClientApplication[]> {
|
||||
const rows = await this.db(TABLE)
|
||||
.update({ announced: true })
|
||||
.where('announced', false)
|
||||
@ -153,6 +157,28 @@ class ClientApplicationsDb {
|
||||
.returning(COLUMNS);
|
||||
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);
|
||||
}
|
||||
}
|
@ -1,6 +1,12 @@
|
||||
/* eslint camelcase: "off" */
|
||||
|
||||
'use strict';
|
||||
import EventEmitter from 'events';
|
||||
import { Knex } from 'knex';
|
||||
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 { DB_TIME } = require('../metric-events');
|
||||
@ -17,7 +23,7 @@ const TABLE = 'client_instances';
|
||||
|
||||
const ONE_DAY = 24 * 61 * 60 * 1000;
|
||||
|
||||
const mapRow = row => ({
|
||||
const mapRow = (row) => ({
|
||||
appName: row.app_name,
|
||||
instanceId: row.instance_id,
|
||||
sdkVersion: row.sdk_version,
|
||||
@ -26,7 +32,7 @@ const mapRow = row => ({
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
|
||||
const mapToDb = client => ({
|
||||
const mapToDb = (client) => ({
|
||||
app_name: client.appName,
|
||||
instance_id: client.instanceId,
|
||||
sdk_version: client.sdkVersion || '',
|
||||
@ -34,12 +40,22 @@ const mapToDb = client => ({
|
||||
last_seen: client.lastSeen || 'now()',
|
||||
});
|
||||
|
||||
class ClientInstanceStore {
|
||||
constructor(db, eventBus, getLogger) {
|
||||
export default class ClientInstanceStore implements IClientInstanceStore {
|
||||
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.eventBus = eventBus;
|
||||
this.logger = getLogger('client-instance-store.js');
|
||||
this.metricTimer = action =>
|
||||
this.logger = getLogger('client-instance-store.ts');
|
||||
this.metricTimer = (action) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'instance',
|
||||
action,
|
||||
@ -49,7 +65,7 @@ class ClientInstanceStore {
|
||||
this.timer = setInterval(clearer, ONE_DAY).unref();
|
||||
}
|
||||
|
||||
async _removeInstancesOlderThanTwoDays() {
|
||||
async _removeInstancesOlderThanTwoDays(): Promise<void> {
|
||||
const rows = await this.db(TABLE)
|
||||
.whereRaw("created_at < now() - interval '2 days'")
|
||||
.del();
|
||||
@ -59,15 +75,44 @@ class ClientInstanceStore {
|
||||
}
|
||||
}
|
||||
|
||||
async bulkUpsert(instances) {
|
||||
async bulkUpsert(instances: INewClientInstance[]): Promise<void> {
|
||||
const rows = instances.map(mapToDb);
|
||||
return this.db(TABLE)
|
||||
await this.db(TABLE)
|
||||
.insert(rows)
|
||||
.onConflict(['app_name', 'instance_id'])
|
||||
.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(
|
||||
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE app_name = ? AND instance_id = ?) AS present`,
|
||||
[appName, instanceId],
|
||||
@ -76,20 +121,18 @@ class ClientInstanceStore {
|
||||
return present;
|
||||
}
|
||||
|
||||
async insert(details) {
|
||||
async insert(details: INewClientInstance): Promise<void> {
|
||||
const stopTimer = this.metricTimer('insert');
|
||||
|
||||
const item = await this.db(TABLE)
|
||||
await this.db(TABLE)
|
||||
.insert(mapToDb(details))
|
||||
.onConflict(['app_name', 'instance_id'])
|
||||
.merge();
|
||||
|
||||
stopTimer();
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
async getAll(): Promise<IClientInstance[]> {
|
||||
const stopTimer = this.metricTimer('getAll');
|
||||
|
||||
const rows = await this.db
|
||||
@ -104,7 +147,7 @@ class ClientInstanceStore {
|
||||
return toggles;
|
||||
}
|
||||
|
||||
async getByAppName(appName) {
|
||||
async getByAppName(appName: string): Promise<IClientInstance[]> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(TABLE)
|
||||
@ -114,25 +157,21 @@ class ClientInstanceStore {
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
|
||||
async getApplications() {
|
||||
async getDistinctApplications(): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.distinct('app_name')
|
||||
.select(['app_name'])
|
||||
.from(TABLE)
|
||||
.orderBy('app_name', 'desc');
|
||||
|
||||
return rows.map(mapRow);
|
||||
return rows.map((r) => r.app_name);
|
||||
}
|
||||
|
||||
async deleteForApplication(appName) {
|
||||
return this.db(TABLE)
|
||||
.where('app_name', appName)
|
||||
.del();
|
||||
async deleteForApplication(appName: string): Promise<void> {
|
||||
return this.db(TABLE).where('app_name', appName).del();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
destroy(): void {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClientInstanceStore;
|
@ -1,23 +1,18 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import { IClientMetric } from '../types/stores/client-metrics-db';
|
||||
|
||||
const METRICS_COLUMNS = ['id', 'created_at', 'metrics'];
|
||||
const TABLE = 'client_metrics';
|
||||
|
||||
const ONE_MINUTE = 60 * 1000;
|
||||
|
||||
const mapRow = row => ({
|
||||
const mapRow = (row) => ({
|
||||
id: row.id,
|
||||
createdAt: row.created_at,
|
||||
metrics: row.metrics,
|
||||
});
|
||||
|
||||
export interface IClientMetric {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
metrics: any;
|
||||
}
|
||||
|
||||
export class ClientMetricsDb {
|
||||
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
|
||||
async insert(metrics: IClientMetric): Promise<void> {
|
||||
return this.db(TABLE).insert({ metrics });
|
||||
@ -66,6 +69,24 @@ export class ClientMetricsDb {
|
||||
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
|
||||
async getNewMetrics(lastKnownId: number): Promise<IClientMetric[]> {
|
||||
try {
|
||||
|
@ -23,7 +23,7 @@ function getMockDb() {
|
||||
};
|
||||
}
|
||||
|
||||
test('should call database on startup', done => {
|
||||
test('should call database on startup', (done) => {
|
||||
jest.useFakeTimers('modern');
|
||||
const mock = getMockDb();
|
||||
const ee = new EventEmitter();
|
||||
@ -33,7 +33,7 @@ test('should call database on startup', done => {
|
||||
|
||||
expect.assertions(2);
|
||||
|
||||
store.on('metrics', metrics => {
|
||||
store.on('metrics', (metrics) => {
|
||||
expect(store.highestIdSeen).toBe(1);
|
||||
expect(metrics.appName).toBe('test');
|
||||
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');
|
||||
getLogger.setMuteError(true);
|
||||
const mock = getMockDb();
|
||||
@ -52,7 +52,7 @@ test('should start poller even if initial database fetch fails', done => {
|
||||
jest.runAllTicks();
|
||||
|
||||
const metrics = [];
|
||||
store.on('metrics', m => metrics.push(m));
|
||||
store.on('metrics', (m) => metrics.push(m));
|
||||
|
||||
store.on('ready', () => {
|
||||
jest.useFakeTimers('modern');
|
||||
@ -69,7 +69,7 @@ test('should start poller even if initial database fetch fails', done => {
|
||||
getLogger.setMuteError(false);
|
||||
});
|
||||
|
||||
test('should poll for updates', done => {
|
||||
test('should poll for updates', (done) => {
|
||||
jest.useFakeTimers('modern');
|
||||
const mock = getMockDb();
|
||||
const ee = new EventEmitter();
|
||||
@ -77,7 +77,7 @@ test('should poll for updates', done => {
|
||||
jest.runAllTicks();
|
||||
|
||||
const metrics = [];
|
||||
store.on('metrics', m => metrics.push(m));
|
||||
store.on('metrics', (m) => metrics.push(m));
|
||||
|
||||
expect(metrics).toHaveLength(0);
|
||||
|
||||
|
@ -1,14 +1,17 @@
|
||||
'use strict';
|
||||
|
||||
import EventEmitter from 'events';
|
||||
import { ClientMetricsDb, IClientMetric } from './client-metrics-db';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import metricsHelper from '../util/metrics-helper';
|
||||
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;
|
||||
|
||||
export class ClientMetricsStore extends EventEmitter {
|
||||
export class ClientMetricsStore
|
||||
extends EventEmitter
|
||||
implements IClientMetricsStore
|
||||
{
|
||||
private logger: Logger;
|
||||
|
||||
highestIdSeen = 0;
|
||||
@ -24,11 +27,11 @@ export class ClientMetricsStore extends EventEmitter {
|
||||
pollInterval = TEN_SECONDS,
|
||||
) {
|
||||
super();
|
||||
this.logger = getLogger('client-metrics-store.js');
|
||||
this.logger = getLogger('client-metrics-store.ts.js');
|
||||
this.metricsDb = metricsDb;
|
||||
this.highestIdSeen = 0;
|
||||
|
||||
this.startTimer = action =>
|
||||
this.startTimer = (action) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'metrics',
|
||||
action,
|
||||
@ -58,13 +61,13 @@ export class ClientMetricsStore extends EventEmitter {
|
||||
_fetchNewAndEmit(): void {
|
||||
this.metricsDb
|
||||
.getNewMetrics(this.highestIdSeen)
|
||||
.then(metrics => this._emitMetrics(metrics));
|
||||
.then((metrics) => this._emitMetrics(metrics));
|
||||
}
|
||||
|
||||
_emitMetrics(metrics: IClientMetric[]): void {
|
||||
if (metrics && metrics.length > 0) {
|
||||
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);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import {
|
||||
IContextField,
|
||||
IContextFieldStore,
|
||||
} from '../types/stores/context-field-store';
|
||||
|
||||
const COLUMNS = [
|
||||
'name',
|
||||
@ -11,7 +15,7 @@ const COLUMNS = [
|
||||
];
|
||||
const TABLE = 'context_fields';
|
||||
|
||||
const mapRow: (IContextRow) => IContextField = row => ({
|
||||
const mapRow: (IContextRow) => IContextField = (row) => ({
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
stickiness: row.stickiness,
|
||||
@ -20,7 +24,7 @@ const mapRow: (IContextRow) => IContextField = row => ({
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
|
||||
export interface ICreateContextField {
|
||||
interface ICreateContextField {
|
||||
name: string;
|
||||
description: string;
|
||||
stickiness: boolean;
|
||||
@ -29,23 +33,14 @@ export interface ICreateContextField {
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface IContextField {
|
||||
name: string;
|
||||
description: string;
|
||||
stickiness: boolean;
|
||||
sortOrder: number;
|
||||
legalValues?: string[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
class ContextFieldStore {
|
||||
class ContextFieldStore implements IContextFieldStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(db: Knex, getLogger: LogProvider) {
|
||||
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
|
||||
@ -69,30 +64,48 @@ class ContextFieldStore {
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
|
||||
async get(name: string): Promise<IContextField> {
|
||||
async get(key: string): Promise<IContextField> {
|
||||
return this.db
|
||||
.first(COLUMNS)
|
||||
.from(TABLE)
|
||||
.where({ name })
|
||||
.where({ name: key })
|
||||
.then(mapRow);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
async create(contextField): Promise<void> {
|
||||
return this.db(TABLE).insert(this.fieldToRow(contextField));
|
||||
async deleteAll(): Promise<void> {
|
||||
await this.db(TABLE).del();
|
||||
}
|
||||
|
||||
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
|
||||
async update(data): Promise<void> {
|
||||
return this.db(TABLE)
|
||||
async create(contextField): Promise<IContextField> {
|
||||
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 })
|
||||
.update(this.fieldToRow(data));
|
||||
.update(this.fieldToRow(data))
|
||||
.returning('*');
|
||||
return mapRow(row);
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
return this.db(TABLE)
|
||||
.where({ name })
|
||||
.del();
|
||||
return this.db(TABLE).where({ name }).del();
|
||||
}
|
||||
}
|
||||
export default ContextFieldStore;
|
||||
|
@ -14,9 +14,9 @@ export function createDb({
|
||||
searchPath: db.schema,
|
||||
asyncStackTraces: true,
|
||||
log: {
|
||||
debug: msg => logger.debug(msg),
|
||||
warn: msg => logger.warn(msg),
|
||||
error: msg => logger.error(msg),
|
||||
debug: (msg) => logger.debug(msg),
|
||||
warn: (msg) => logger.warn(msg),
|
||||
error: (msg) => logger.error(msg),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import metricsHelper from '../util/metrics-helper';
|
||||
import { DB_TIME } from '../metric-events';
|
||||
import { IEnvironment } from '../types/model';
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import { IEnvironmentStore } from '../types/stores/environment-store';
|
||||
|
||||
interface IEnvironmentsTable {
|
||||
name: string;
|
||||
@ -34,7 +35,7 @@ function mapInput(input: IEnvironment): IEnvironmentsTable {
|
||||
|
||||
const TABLE = 'environments';
|
||||
|
||||
export default class EnvironmentStore {
|
||||
export default class EnvironmentStore implements IEnvironmentStore {
|
||||
private logger: Logger;
|
||||
|
||||
private db: Knex;
|
||||
@ -44,19 +45,33 @@ export default class EnvironmentStore {
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('db/environment-store.ts');
|
||||
this.timer = action =>
|
||||
this.timer = (action) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'environment',
|
||||
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[]> {
|
||||
const rows = await this.db<IEnvironmentsTable>(TABLE).select('*');
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
|
||||
async exists(name: string): Promise<Boolean> {
|
||||
async exists(name: string): Promise<boolean> {
|
||||
const result = await this.db.raw(
|
||||
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`,
|
||||
[name],
|
||||
@ -104,7 +119,7 @@ export default class EnvironmentStore {
|
||||
.where({
|
||||
project: projectId,
|
||||
});
|
||||
const rows: IFeatureEnvironmentRow[] = featuresToEnable.map(f => ({
|
||||
const rows: IFeatureEnvironmentRow[] = featuresToEnable.map((f) => ({
|
||||
environment,
|
||||
feature_name: f.name,
|
||||
enabled: false,
|
||||
@ -118,9 +133,7 @@ export default class EnvironmentStore {
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
await this.db(TABLE)
|
||||
.where({ name })
|
||||
.del();
|
||||
await this.db(TABLE).where({ name }).del();
|
||||
}
|
||||
|
||||
async disconnectProjectFromEnv(
|
||||
@ -140,7 +153,7 @@ export default class EnvironmentStore {
|
||||
.select('environment_name')
|
||||
.where({ project_id });
|
||||
await Promise.all(
|
||||
environmentsToEnable.map(async env => {
|
||||
environmentsToEnable.map(async (env) => {
|
||||
await this.db('feature_environments')
|
||||
.insert({
|
||||
environment: env.environment_name,
|
||||
@ -152,4 +165,6 @@ export default class EnvironmentStore {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ test('Trying to get events by name if db fails should yield empty list', async (
|
||||
client: 'pg',
|
||||
});
|
||||
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.length).toBe(0);
|
||||
});
|
||||
|
@ -2,7 +2,8 @@ import { EventEmitter } from 'events';
|
||||
import { Knex } from 'knex';
|
||||
import { DROP_FEATURES } from '../types/events';
|
||||
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 = [
|
||||
'id',
|
||||
@ -13,7 +14,7 @@ const EVENT_COLUMNS = [
|
||||
'tags',
|
||||
];
|
||||
|
||||
interface IEventTable {
|
||||
export interface IEventTable {
|
||||
id: number;
|
||||
type: string;
|
||||
created_by: string;
|
||||
@ -22,21 +23,9 @@ interface IEventTable {
|
||||
tags: [];
|
||||
}
|
||||
|
||||
export interface ICreateEvent {
|
||||
type: string;
|
||||
createdBy: string;
|
||||
data?: any;
|
||||
tags?: ITag[];
|
||||
}
|
||||
|
||||
export interface IEvent extends ICreateEvent {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const TABLE = 'events';
|
||||
|
||||
class EventStore extends EventEmitter {
|
||||
class EventStore extends EventEmitter implements IEventStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
@ -44,7 +33,7 @@ class EventStore extends EventEmitter {
|
||||
constructor(db: Knex, getLogger: LogProvider) {
|
||||
super();
|
||||
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> {
|
||||
@ -66,13 +55,41 @@ class EventStore extends EventEmitter {
|
||||
.returning(EVENT_COLUMNS);
|
||||
const savedEvents = savedRows.map(this.rowToEvent);
|
||||
process.nextTick(() =>
|
||||
savedEvents.forEach(e => this.emit(e.type, e)),
|
||||
savedEvents.forEach((e) => this.emit(e.type, e)),
|
||||
);
|
||||
} catch (e) {
|
||||
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[]> {
|
||||
try {
|
||||
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 {
|
||||
const rows = await this.db
|
||||
.select(EVENT_COLUMNS)
|
||||
@ -116,7 +133,7 @@ class EventStore extends EventEmitter {
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at,
|
||||
data: row.data,
|
||||
tags: row.tags,
|
||||
tags: row.tags || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
195
src/lib/db/feature-environment-store.ts
Normal file
195
src/lib/db/feature-environment-store.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -4,18 +4,16 @@ import * as uuid from 'uuid';
|
||||
import metricsHelper from '../util/metrics-helper';
|
||||
import { DB_TIME } from '../metric-events';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import {
|
||||
FeatureToggleWithEnvironment,
|
||||
IConstraint,
|
||||
IEnvironmentOverview,
|
||||
IFeatureOverview,
|
||||
IFeatureStrategy,
|
||||
IFeatureToggleClient,
|
||||
IFeatureToggleQuery,
|
||||
IStrategyConfig,
|
||||
IVariant,
|
||||
FeatureToggleWithEnvironment,
|
||||
IFeatureEnvironment,
|
||||
} from '../types/model';
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
|
||||
|
||||
const COLUMNS = [
|
||||
'id',
|
||||
@ -52,26 +50,6 @@ interface IFeatureStrategiesTable {
|
||||
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 {
|
||||
return {
|
||||
id: row.id,
|
||||
@ -80,7 +58,7 @@ function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy {
|
||||
environment: row.environment,
|
||||
strategyName: row.strategy_name,
|
||||
parameters: row.parameters,
|
||||
constraints: ((row.constraints as unknown) as IConstraint[]) || [],
|
||||
constraints: (row.constraints as unknown as IConstraint[]) || [],
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
@ -118,7 +96,7 @@ function mapStrategyUpdate(
|
||||
return update;
|
||||
}
|
||||
|
||||
class FeatureStrategiesStore {
|
||||
class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
@ -128,13 +106,39 @@ class FeatureStrategiesStore {
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('feature-toggle-store.ts');
|
||||
this.timer = action =>
|
||||
this.timer = (action) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'feature-toggle-strategies',
|
||||
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(
|
||||
strategyConfig: Omit<IFeatureStrategy, 'id' | 'createdAt'>,
|
||||
): Promise<IFeatureStrategy> {
|
||||
@ -163,10 +167,6 @@ class FeatureStrategiesStore {
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
|
||||
async deleteFeatureStrategies(): Promise<void> {
|
||||
await this.db(T.featureStrategies).delete();
|
||||
}
|
||||
|
||||
async getStrategiesForEnvironment(
|
||||
environment: string,
|
||||
): Promise<IFeatureStrategy[]> {
|
||||
@ -394,61 +394,13 @@ class FeatureStrategiesStore {
|
||||
}
|
||||
|
||||
async getStrategyById(id: string): Promise<IFeatureStrategy> {
|
||||
const strat = await this.db(T.featureStrategies)
|
||||
.where({ id })
|
||||
.first();
|
||||
const strat = await this.db(T.featureStrategies).where({ id }).first();
|
||||
if (strat) {
|
||||
return mapRow(strat);
|
||||
}
|
||||
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(
|
||||
id: string,
|
||||
updates: Partial<IFeatureStrategy>,
|
||||
@ -477,26 +429,6 @@ class FeatureStrategiesStore {
|
||||
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(
|
||||
environment: string,
|
||||
featureName: string,
|
||||
@ -530,58 +462,6 @@ class FeatureStrategiesStore {
|
||||
.where({ project_name: projectId, environment })
|
||||
.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;
|
||||
|
@ -6,6 +6,11 @@ import metricsHelper from '../util/metrics-helper';
|
||||
import { DB_TIME } from '../metric-events';
|
||||
import { UNIQUE_CONSTRAINT_VIOLATION } from '../error/db-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 TABLE = 'feature_tag';
|
||||
@ -16,18 +21,7 @@ interface FeatureTagTable {
|
||||
tag_value: string;
|
||||
}
|
||||
|
||||
export interface IFeatureTag {
|
||||
featureName: string;
|
||||
tagType: string;
|
||||
tagValue: string;
|
||||
}
|
||||
|
||||
export interface IFeatureAndTag {
|
||||
featureName: string;
|
||||
tag: ITag;
|
||||
}
|
||||
|
||||
class FeatureTagStore {
|
||||
class FeatureTagStore implements IFeatureTagStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
@ -36,14 +30,71 @@ class FeatureTagStore {
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('feature-tag-store.js');
|
||||
this.timer = action =>
|
||||
this.logger = getLogger('feature-tag-store.ts');
|
||||
this.timer = (action) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'feature-toggle',
|
||||
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[]> {
|
||||
const stopTimer = this.timer('getAllForFeature');
|
||||
const rows = await this.db
|
||||
@ -58,7 +109,7 @@ class FeatureTagStore {
|
||||
const stopTimer = this.timer('tagFeature');
|
||||
await this.db<FeatureTagTable>(TABLE)
|
||||
.insert(this.featureAndTagToRow(featureName, tag))
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
if (err.code === UNIQUE_CONSTRAINT_VIOLATION) {
|
||||
throw new FeatureHasTagError(
|
||||
`${featureName} already had the tag: [${tag.type}:${tag.value}]`,
|
||||
@ -79,19 +130,17 @@ class FeatureTagStore {
|
||||
.select(COLUMNS)
|
||||
.whereIn(
|
||||
'feature_name',
|
||||
this.db('features')
|
||||
.where({ archived: false })
|
||||
.select(['name']),
|
||||
this.db('features').where({ archived: false }).select(['name']),
|
||||
);
|
||||
return rows.map(row => ({
|
||||
return rows.map((row) => ({
|
||||
featureName: row.feature_name,
|
||||
tagType: row.tag_type,
|
||||
tagValue: row.tag_value,
|
||||
}));
|
||||
}
|
||||
|
||||
async dropFeatureTags(): Promise<void> {
|
||||
const stopTimer = this.timer('dropFeatureTags');
|
||||
async deleteAll(): Promise<void> {
|
||||
const stopTimer = this.timer('deleteAll');
|
||||
await this.db(TABLE).del();
|
||||
stopTimer();
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { DB_TIME } from '../metric-events';
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import { FeatureToggleDTO, FeatureToggle, IVariant } from '../types/model';
|
||||
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
|
||||
|
||||
const FEATURE_COLUMNS = [
|
||||
'name',
|
||||
@ -30,7 +31,7 @@ export interface FeaturesTable {
|
||||
|
||||
const TABLE = 'features';
|
||||
|
||||
export default class FeatureToggleStore {
|
||||
export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
@ -40,7 +41,7 @@ export default class FeatureToggleStore {
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('feature-toggle-store.ts');
|
||||
this.timer = action =>
|
||||
this.timer = (action) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'feature-toggle',
|
||||
action,
|
||||
@ -58,7 +59,7 @@ export default class FeatureToggleStore {
|
||||
.count('*')
|
||||
.from(TABLE)
|
||||
.where(query)
|
||||
.then(res => Number(res[0].count));
|
||||
.then((res) => Number(res[0].count));
|
||||
}
|
||||
|
||||
async getFeatureMetadata(name: string): Promise<FeatureToggle> {
|
||||
@ -69,7 +70,29 @@ export default class FeatureToggleStore {
|
||||
.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
|
||||
.select(FEATURE_COLUMNS)
|
||||
.from(TABLE)
|
||||
@ -87,8 +110,8 @@ export default class FeatureToggleStore {
|
||||
.first(['project'])
|
||||
.from(TABLE)
|
||||
.where({ name })
|
||||
.then(r => (r ? r.project : undefined))
|
||||
.catch(e => {
|
||||
.then((r) => (r ? r.project : undefined))
|
||||
.catch((e) => {
|
||||
this.logger.error(e);
|
||||
return undefined;
|
||||
});
|
||||
@ -103,7 +126,7 @@ export default class FeatureToggleStore {
|
||||
.first('name', 'archived')
|
||||
.from(TABLE)
|
||||
.where({ name })
|
||||
.then(row => {
|
||||
.then((row) => {
|
||||
if (!row) {
|
||||
throw new NotFoundError('No feature toggle found');
|
||||
}
|
||||
@ -116,7 +139,7 @@ export default class FeatureToggleStore {
|
||||
|
||||
async exists(name: string): Promise<boolean> {
|
||||
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],
|
||||
);
|
||||
const { present } = result.rows[0];
|
||||
@ -132,7 +155,7 @@ export default class FeatureToggleStore {
|
||||
return rows.map(this.rowToFeature);
|
||||
}
|
||||
|
||||
async lastSeenToggles(toggleNames: string[]): Promise<void> {
|
||||
async updateLastSeenForToggles(toggleNames: string[]): Promise<void> {
|
||||
const now = new Date();
|
||||
try {
|
||||
await this.db(TABLE)
|
||||
@ -160,7 +183,7 @@ export default class FeatureToggleStore {
|
||||
type: row.type,
|
||||
project: row.project,
|
||||
stale: row.stale,
|
||||
variants: (row.variants as unknown) as IVariant[],
|
||||
variants: row.variants as unknown as IVariant[],
|
||||
createdAt: row.created_at,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
};
|
||||
@ -218,7 +241,7 @@ export default class FeatureToggleStore {
|
||||
return this.rowToFeature(row[0]);
|
||||
}
|
||||
|
||||
async deleteFeature(name: string): Promise<void> {
|
||||
async delete(name: string): Promise<void> {
|
||||
await this.db(TABLE)
|
||||
.where({ name, archived: true }) // Feature toggle must be archived to allow deletion
|
||||
.del();
|
||||
@ -232,14 +255,6 @@ export default class FeatureToggleStore {
|
||||
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: {
|
||||
archived?: boolean;
|
||||
project?: string;
|
||||
|
@ -1,16 +1,13 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import {
|
||||
IFeatureType,
|
||||
IFeatureTypeStore,
|
||||
} from '../types/stores/feature-type-store';
|
||||
|
||||
const COLUMNS = ['id', 'name', 'description', 'lifetime_days'];
|
||||
const TABLE = 'feature_types';
|
||||
|
||||
export interface IFeatureType {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
lifetimeDays: number;
|
||||
}
|
||||
|
||||
interface IFeatureTypeRow {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -18,14 +15,14 @@ interface IFeatureTypeRow {
|
||||
lifetime_days: number;
|
||||
}
|
||||
|
||||
class FeatureTypeStore {
|
||||
class FeatureTypeStore implements IFeatureTypeStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(db: Knex, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('feature-type-store.js');
|
||||
this.logger = getLogger('feature-type-store.ts');
|
||||
}
|
||||
|
||||
async getAll(): Promise<IFeatureType[]> {
|
||||
@ -33,7 +30,7 @@ class FeatureTypeStore {
|
||||
return rows.map(this.rowToFeatureType);
|
||||
}
|
||||
|
||||
rowToFeatureType(row: IFeatureTypeRow): IFeatureType {
|
||||
private rowToFeatureType(row: IFeatureTypeRow): IFeatureType {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@ -41,6 +38,35 @@ class FeatureTypeStore {
|
||||
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;
|
||||
module.exports = FeatureTypeStore;
|
||||
|
@ -1,9 +1,8 @@
|
||||
// eslint-disable-next-line
|
||||
import EventEmitter from 'events';
|
||||
import { Knex } from 'knex';
|
||||
import { IUnleashConfig } from '../types/option';
|
||||
import { IUnleashStores } from '../types/stores';
|
||||
|
||||
import { createDb } from './db-pool';
|
||||
import EventStore from './event-store';
|
||||
import FeatureToggleStore from './feature-toggle-store';
|
||||
import FeatureTypeStore from './feature-type-store';
|
||||
@ -27,23 +26,27 @@ import UserFeedbackStore from './user-feedback-store';
|
||||
import FeatureStrategyStore from './feature-strategy-store';
|
||||
import EnvironmentStore from './environment-store';
|
||||
import FeatureTagStore from './feature-tag-store';
|
||||
import { FeatureEnvironmentStore } from './feature-environment-store';
|
||||
|
||||
export const createStores = (
|
||||
config: IUnleashConfig,
|
||||
eventBus: EventEmitter,
|
||||
db: Knex,
|
||||
): IUnleashStores => {
|
||||
const { getLogger } = config;
|
||||
const db = createDb(config);
|
||||
const eventStore = new EventStore(db, getLogger);
|
||||
const clientMetricsDb = new ClientMetricsDb(db, getLogger);
|
||||
|
||||
return {
|
||||
db,
|
||||
eventStore,
|
||||
featureToggleStore: new FeatureToggleStore(db, eventBus, getLogger),
|
||||
featureTypeStore: new FeatureTypeStore(db, getLogger),
|
||||
strategyStore: new StrategyStore(db, getLogger),
|
||||
clientApplicationsStore: new ClientApplicationsStore(db, eventBus),
|
||||
clientApplicationsStore: new ClientApplicationsStore(
|
||||
db,
|
||||
eventBus,
|
||||
getLogger,
|
||||
),
|
||||
clientInstanceStore: new ClientInstanceStore(db, eventBus, getLogger),
|
||||
clientMetricsStore: new ClientMetricsStore(
|
||||
clientMetricsDb,
|
||||
@ -69,6 +72,11 @@ export const createStores = (
|
||||
),
|
||||
environmentStore: new EnvironmentStore(db, eventBus, getLogger),
|
||||
featureTagStore: new FeatureTagStore(db, eventBus, getLogger),
|
||||
featureEnvironmentStore: new FeatureEnvironmentStore(
|
||||
db,
|
||||
eventBus,
|
||||
getLogger,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -3,44 +3,27 @@ import { Logger, LogProvider } from '../logger';
|
||||
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
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 TABLE = 'projects';
|
||||
|
||||
export interface IProject {
|
||||
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 {
|
||||
class ProjectStore implements IProjectStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(db: Knex, getLogger: LogProvider) {
|
||||
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 {
|
||||
return {
|
||||
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[]> {
|
||||
const rows = await this.db
|
||||
.select(COLUMNS)
|
||||
@ -66,20 +60,13 @@ class ProjectStore {
|
||||
.then(this.mapRow);
|
||||
}
|
||||
|
||||
async hasProject(id: string): Promise<IProjectArchived> {
|
||||
return this.db
|
||||
.first('id')
|
||||
.from(TABLE)
|
||||
.where({ id })
|
||||
.then(row => {
|
||||
if (!row) {
|
||||
throw new NotFoundError(`No project with id=${id} found`);
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
archived: row.archived === 1,
|
||||
};
|
||||
});
|
||||
async hasProject(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 updateHealth(healthUpdate: IProjectHealthUpdate): Promise<void> {
|
||||
@ -88,13 +75,14 @@ class ProjectStore {
|
||||
.update({ health: healthUpdate.health });
|
||||
}
|
||||
|
||||
async create(project): Promise<IProject> {
|
||||
const [id] = await this.db(TABLE)
|
||||
async create(project: IProjectInsert): Promise<IProject> {
|
||||
const row = await this.db(TABLE)
|
||||
.insert(this.fieldToRow(project))
|
||||
.returning('id');
|
||||
return { ...project, id };
|
||||
.returning('*');
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
async update(data): Promise<void> {
|
||||
try {
|
||||
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)
|
||||
.insert(projects.map(this.fieldToRow))
|
||||
.returning(COLUMNS)
|
||||
@ -118,8 +106,8 @@ class ProjectStore {
|
||||
return [];
|
||||
}
|
||||
|
||||
async addGlobalEnvironment(projects): Promise<void> {
|
||||
const environments = projects.map(p => ({
|
||||
async addGlobalEnvironment(projects: any[]): Promise<void> {
|
||||
const environments = projects.map((p) => ({
|
||||
project_id: p.id,
|
||||
environment_name: ':global:',
|
||||
}));
|
||||
@ -129,21 +117,22 @@ class ProjectStore {
|
||||
.ignore();
|
||||
}
|
||||
|
||||
async dropProjects(): Promise<void> {
|
||||
async deleteAll(): Promise<void> {
|
||||
await this.db(TABLE).del();
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
await this.db(TABLE)
|
||||
.where({ id })
|
||||
.del();
|
||||
await this.db(TABLE).where({ id }).del();
|
||||
} catch (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')
|
||||
.where({
|
||||
project_id: id,
|
||||
@ -152,6 +141,24 @@ class ProjectStore {
|
||||
.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> {
|
||||
const rolesFromProject = this.db('role_permission')
|
||||
.select('role_id')
|
||||
@ -215,7 +222,7 @@ class ProjectStore {
|
||||
}, {});
|
||||
return Object.values(overview).map((o: IFeatureOverview) => ({
|
||||
...o,
|
||||
environments: o.environments.filter(f => f.name),
|
||||
environments: o.environments.filter((f) => f.name),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
@ -230,6 +237,7 @@ class ProjectStore {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
mapRow(row): IProject {
|
||||
if (!row) {
|
||||
throw new NotFoundError('No project found');
|
||||
|
@ -4,6 +4,13 @@ import metricsHelper from '../util/metrics-helper';
|
||||
import { DB_TIME } from '../metric-events';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import {
|
||||
IResetQuery,
|
||||
IResetToken,
|
||||
IResetTokenCreate,
|
||||
IResetTokenQuery,
|
||||
IResetTokenStore,
|
||||
} from '../types/stores/reset-token-store';
|
||||
|
||||
const TABLE = 'reset_tokens';
|
||||
|
||||
@ -16,44 +23,16 @@ interface IResetTokenTable {
|
||||
used_at: Date;
|
||||
}
|
||||
|
||||
export interface IResetTokenCreate {
|
||||
reset_token: string;
|
||||
user_id: number;
|
||||
expires_at: Date;
|
||||
created_by?: string;
|
||||
}
|
||||
const rowToResetToken = (row: IResetTokenTable): IResetToken => ({
|
||||
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 interface IResetToken {
|
||||
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 {
|
||||
export class ResetTokenStore implements IResetTokenStore {
|
||||
private logger: Logger;
|
||||
|
||||
private timer: Function;
|
||||
@ -62,7 +41,7 @@ export class ResetTokenStore {
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('db/reset-token-store.js');
|
||||
this.logger = getLogger('db/reset-token-store.ts');
|
||||
this.timer = (action: string) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'reset-tokens',
|
||||
@ -113,10 +92,8 @@ export class ResetTokenStore {
|
||||
}
|
||||
}
|
||||
|
||||
async delete({ reset_token }: IResetTokenQuery): Promise<void> {
|
||||
return this.db(TABLE)
|
||||
.where(reset_token)
|
||||
.del();
|
||||
async deleteFromQuery({ reset_token }: IResetTokenQuery): Promise<void> {
|
||||
return this.db(TABLE).where(reset_token).del();
|
||||
}
|
||||
|
||||
async deleteAll(): Promise<void> {
|
||||
@ -124,16 +101,37 @@ export class ResetTokenStore {
|
||||
}
|
||||
|
||||
async deleteExpired(): Promise<void> {
|
||||
return this.db(TABLE)
|
||||
.where('expires_at', '<', new Date())
|
||||
.del();
|
||||
return this.db(TABLE).where('expires_at', '<', new Date()).del();
|
||||
}
|
||||
|
||||
async expireExistingTokensForUser(user_id: number): Promise<void> {
|
||||
await this.db<IResetTokenTable>(TABLE)
|
||||
.where({ user_id })
|
||||
.update({
|
||||
expires_at: new Date(),
|
||||
});
|
||||
await this.db<IResetTokenTable>(TABLE).where({ user_id }).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);
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import EventEmitter from 'events';
|
||||
import { Knex } from 'knex';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import { ISession, ISessionStore } from '../types/stores/session-store';
|
||||
|
||||
const TABLE = 'unleash_session';
|
||||
|
||||
@ -12,14 +13,7 @@ interface ISessionRow {
|
||||
expired?: Date;
|
||||
}
|
||||
|
||||
export interface ISession {
|
||||
sid: string;
|
||||
sess: any;
|
||||
createdAt: Date;
|
||||
expired?: Date;
|
||||
}
|
||||
|
||||
export default class SessionStore {
|
||||
export default class SessionStore implements ISessionStore {
|
||||
private logger: Logger;
|
||||
|
||||
private eventBus: EventEmitter;
|
||||
@ -41,9 +35,10 @@ export default class SessionStore {
|
||||
}
|
||||
|
||||
async getSessionsForUser(userId: number): Promise<ISession[]> {
|
||||
const rows = await this.db<ISessionRow>(
|
||||
TABLE,
|
||||
).whereRaw(`(sess -> 'user' ->> 'id')::int = ?`, [userId]);
|
||||
const rows = await this.db<ISessionRow>(TABLE).whereRaw(
|
||||
"(sess -> 'user' ->> 'id')::int = ?",
|
||||
[userId],
|
||||
);
|
||||
if (rows && rows.length > 0) {
|
||||
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)
|
||||
.where('sid', '=', sid)
|
||||
.first();
|
||||
@ -64,14 +59,12 @@ export default class SessionStore {
|
||||
|
||||
async deleteSessionsForUser(userId: number): Promise<void> {
|
||||
await this.db<ISessionRow>(TABLE)
|
||||
.whereRaw(`(sess -> 'user' ->> 'id')::int = ?`, [userId])
|
||||
.whereRaw("(sess -> 'user' ->> 'id')::int = ?", [userId])
|
||||
.del();
|
||||
}
|
||||
|
||||
async deleteSession(sid: string): Promise<void> {
|
||||
await this.db<ISessionRow>(TABLE)
|
||||
.where('sid', '=', sid)
|
||||
.del();
|
||||
async delete(sid: string): Promise<void> {
|
||||
await this.db<ISessionRow>(TABLE).where('sid', '=', sid).del();
|
||||
}
|
||||
|
||||
async insertSession(data: Omit<ISession, 'createdAt'>): Promise<ISession> {
|
||||
@ -92,6 +85,22 @@ export default class SessionStore {
|
||||
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 {
|
||||
return {
|
||||
sid: row.sid,
|
||||
|
@ -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;
|
75
src/lib/db/setting-store.ts
Normal file
75
src/lib/db/setting-store.ts
Normal 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;
|
@ -2,6 +2,12 @@ import { Knex } from 'knex';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import {
|
||||
IEditableStrategy,
|
||||
IMinimalStrategyRow,
|
||||
IStrategy,
|
||||
IStrategyStore,
|
||||
} from '../types/stores/strategy-store';
|
||||
|
||||
const STRATEGY_COLUMNS = [
|
||||
'name',
|
||||
@ -13,47 +19,25 @@ const STRATEGY_COLUMNS = [
|
||||
];
|
||||
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 {
|
||||
name: string;
|
||||
built_in: number;
|
||||
description: string;
|
||||
parameters: object;
|
||||
parameters: object[];
|
||||
deprecated: boolean;
|
||||
display_name: string;
|
||||
}
|
||||
export default class StrategyStore {
|
||||
export default class StrategyStore implements IStrategyStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(db: Knex, getLogger: LogProvider) {
|
||||
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
|
||||
.select(STRATEGY_COLUMNS)
|
||||
.from(TABLE)
|
||||
@ -82,6 +66,30 @@ export default class StrategyStore {
|
||||
.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 {
|
||||
if (!row) {
|
||||
throw new NotFoundError('No strategy found');
|
||||
@ -109,7 +117,7 @@ export default class StrategyStore {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
eventDataToRow(data): IMinimalStrategy {
|
||||
eventDataToRow(data): IMinimalStrategyRow {
|
||||
return {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
@ -119,67 +127,39 @@ export default class StrategyStore {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
async createStrategy(data): Promise<void> {
|
||||
this.db(TABLE)
|
||||
.insert(this.eventDataToRow(data))
|
||||
.catch(err =>
|
||||
this.logger.error('Could not insert strategy, error: ', err),
|
||||
);
|
||||
await this.db(TABLE).insert(this.eventDataToRow(data));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
async updateStrategy(data): Promise<void> {
|
||||
this.db(TABLE)
|
||||
await this.db(TABLE)
|
||||
.where({ name: data.name })
|
||||
.update(this.eventDataToRow(data))
|
||||
.catch(err =>
|
||||
this.logger.error('Could not update strategy, error: ', err),
|
||||
);
|
||||
.update(this.eventDataToRow(data));
|
||||
}
|
||||
|
||||
async deprecateStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void> {
|
||||
this.db(TABLE)
|
||||
.where({ name })
|
||||
.update({ deprecated: true })
|
||||
.catch(err =>
|
||||
this.logger.error('Could not deprecate strategy, error: ', err),
|
||||
);
|
||||
await this.db(TABLE).where({ name }).update({ deprecated: true });
|
||||
}
|
||||
|
||||
async reactivateStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void> {
|
||||
this.db(TABLE)
|
||||
.where({ name })
|
||||
.update({ deprecated: false })
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Could not reactivate strategy, error: ',
|
||||
err,
|
||||
),
|
||||
);
|
||||
await this.db(TABLE).where({ name }).update({ deprecated: false });
|
||||
}
|
||||
|
||||
async deleteStrategy({ name }: Pick<IStrategy, 'name'>): Promise<void> {
|
||||
await this.db(TABLE)
|
||||
.where({ name })
|
||||
.del()
|
||||
.catch(err => {
|
||||
this.logger.error('Could not delete strategy, error: ', err);
|
||||
});
|
||||
await this.db(TABLE).where({ name }).del();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
async importStrategy(data): Promise<void> {
|
||||
const rowData = this.eventDataToRow(data);
|
||||
await this.db(TABLE)
|
||||
.insert(rowData)
|
||||
.onConflict(['name'])
|
||||
.merge();
|
||||
await this.db(TABLE).insert(rowData).onConflict(['name']).merge();
|
||||
}
|
||||
|
||||
async dropStrategies(): Promise<void> {
|
||||
await this.db(TABLE)
|
||||
.where({ built_in: 0 }) // eslint-disable-line
|
||||
.delete()
|
||||
.catch(err =>
|
||||
.catch((err) =>
|
||||
this.logger.error('Could not drop strategies, error: ', err),
|
||||
);
|
||||
}
|
||||
|
@ -4,6 +4,8 @@ import { DB_TIME } from '../metric-events';
|
||||
import metricsHelper from '../util/metrics-helper';
|
||||
import { LogProvider, Logger } from '../logger';
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import { ITag } from '../types/model';
|
||||
import { ITagStore } from '../types/stores/tag-store';
|
||||
|
||||
const COLUMNS = ['type', 'value'];
|
||||
const TABLE = 'tags';
|
||||
@ -13,12 +15,7 @@ interface ITagTable {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ITag {
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default class TagStore {
|
||||
export default class TagStore implements ITagStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
@ -27,8 +24,8 @@ export default class TagStore {
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('tag-store.js');
|
||||
this.timer = action =>
|
||||
this.logger = getLogger('tag-store.ts');
|
||||
this.timer = (action) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'tag',
|
||||
action,
|
||||
@ -37,10 +34,7 @@ export default class TagStore {
|
||||
|
||||
async getTagsByType(type: string): Promise<ITag[]> {
|
||||
const stopTimer = this.timer('getTagByType');
|
||||
const rows = await this.db
|
||||
.select(COLUMNS)
|
||||
.from(TABLE)
|
||||
.where({ type });
|
||||
const rows = await this.db.select(COLUMNS).from(TABLE).where({ type });
|
||||
stopTimer();
|
||||
return rows.map(this.rowToTag);
|
||||
}
|
||||
@ -84,16 +78,14 @@ export default class TagStore {
|
||||
stopTimer();
|
||||
}
|
||||
|
||||
async deleteTag(tag: ITag): Promise<void> {
|
||||
async delete(tag: ITag): Promise<void> {
|
||||
const stopTimer = this.timer('deleteTag');
|
||||
await this.db(TABLE)
|
||||
.where(tag)
|
||||
.del();
|
||||
await this.db(TABLE).where(tag).del();
|
||||
stopTimer();
|
||||
}
|
||||
|
||||
async dropTags(): Promise<void> {
|
||||
const stopTimer = this.timer('dropTags');
|
||||
async deleteAll(): Promise<void> {
|
||||
const stopTimer = this.timer('deleteAll');
|
||||
await this.db(TABLE).del();
|
||||
stopTimer();
|
||||
}
|
||||
@ -106,6 +98,23 @@ export default class TagStore {
|
||||
.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 {
|
||||
return {
|
||||
type: row.type,
|
||||
|
@ -4,6 +4,7 @@ import { LogProvider, Logger } from '../logger';
|
||||
import { DB_TIME } from '../metric-events';
|
||||
import metricsHelper from '../util/metrics-helper';
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import { ITagType, ITagTypeStore } from '../types/stores/tag-type-store';
|
||||
|
||||
const COLUMNS = ['name', 'description', 'icon'];
|
||||
const TABLE = 'tag_types';
|
||||
@ -14,13 +15,7 @@ interface ITagTypeTable {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ITagType {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export default class TagTypeStore {
|
||||
export default class TagTypeStore implements ITagTypeStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
@ -29,8 +24,8 @@ export default class TagTypeStore {
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('tag-type-store.js');
|
||||
this.timer = action =>
|
||||
this.logger = getLogger('tag-type-store.ts');
|
||||
this.timer = (action) =>
|
||||
metricsHelper.wrapTimer(eventBus, DB_TIME, {
|
||||
store: 'tag-type',
|
||||
action,
|
||||
@ -44,13 +39,13 @@ export default class TagTypeStore {
|
||||
return rows.map(this.rowToTagType);
|
||||
}
|
||||
|
||||
async getTagType(name: string): Promise<ITagType> {
|
||||
async get(name: string): Promise<ITagType> {
|
||||
const stopTimer = this.timer('getTagTypeByName');
|
||||
return this.db
|
||||
.first(COLUMNS)
|
||||
.from(TABLE)
|
||||
.where({ name })
|
||||
.then(row => {
|
||||
.then((row) => {
|
||||
stopTimer();
|
||||
if (!row) {
|
||||
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 result = await this.db.raw(
|
||||
`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE name = ?) AS present`,
|
||||
@ -77,16 +72,14 @@ export default class TagTypeStore {
|
||||
stopTimer();
|
||||
}
|
||||
|
||||
async deleteTagType(name: string): Promise<void> {
|
||||
async delete(name: string): Promise<void> {
|
||||
const stopTimer = this.timer('deleteTagType');
|
||||
await this.db(TABLE)
|
||||
.where({ name })
|
||||
.del();
|
||||
await this.db(TABLE).where({ name }).del();
|
||||
stopTimer();
|
||||
}
|
||||
|
||||
async dropTagTypes(): Promise<void> {
|
||||
const stopTimer = this.timer('dropTagTypes');
|
||||
async deleteAll(): Promise<void> {
|
||||
const stopTimer = this.timer('deleteAll');
|
||||
await this.db(TABLE).del();
|
||||
stopTimer();
|
||||
}
|
||||
@ -105,12 +98,12 @@ export default class TagTypeStore {
|
||||
|
||||
async updateTagType({ name, description, icon }: ITagType): Promise<void> {
|
||||
const stopTimer = this.timer('updateTagType');
|
||||
await this.db(TABLE)
|
||||
.where({ name })
|
||||
.update({ description, icon });
|
||||
await this.db(TABLE).where({ name }).update({ description, icon });
|
||||
stopTimer();
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
|
||||
rowToTagType(row: ITagTypeTable): ITagType {
|
||||
return {
|
||||
name: row.name,
|
||||
|
@ -1,6 +1,11 @@
|
||||
import { Knex } from 'knex';
|
||||
import { EventEmitter } from 'events';
|
||||
import { LogProvider, Logger } from '../logger';
|
||||
import {
|
||||
IUserFeedback,
|
||||
IUserFeedbackKey,
|
||||
IUserFeedbackStore,
|
||||
} from '../types/stores/user-feedback-store';
|
||||
|
||||
const COLUMNS = ['given', 'user_id', 'feedback_id', 'nevershow'];
|
||||
const TABLE = 'user_feedback';
|
||||
@ -12,13 +17,6 @@ interface IUserFeedbackTable {
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
export interface IUserFeedback {
|
||||
neverShow: boolean;
|
||||
feedbackId: string;
|
||||
given?: Date;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
const fieldToRow = (fields: IUserFeedback): IUserFeedbackTable => ({
|
||||
nevershow: fields.neverShow,
|
||||
feedback_id: fields.feedbackId,
|
||||
@ -33,14 +31,14 @@ const rowToField = (row: IUserFeedbackTable): IUserFeedback => ({
|
||||
userId: row.user_id,
|
||||
});
|
||||
|
||||
export default class UserFeedbackStore {
|
||||
export default class UserFeedbackStore implements IUserFeedbackStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('user-feedback-store.js');
|
||||
this.logger = getLogger('user-feedback-store.ts');
|
||||
}
|
||||
|
||||
async getAllUserFeedback(userId: number): Promise<IUserFeedback[]> {
|
||||
@ -75,6 +73,42 @@ export default class UserFeedbackStore {
|
||||
|
||||
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;
|
||||
|
@ -5,6 +5,13 @@ import { Logger, LogProvider } from '../logger';
|
||||
import User from '../types/user';
|
||||
|
||||
import NotFoundError from '../error/notfound-error';
|
||||
import {
|
||||
ICreateUser,
|
||||
IUserLookup,
|
||||
IUserSearch,
|
||||
IUserStore,
|
||||
IUserUpdateFields,
|
||||
} from '../types/stores/user-store';
|
||||
|
||||
const TABLE = 'users';
|
||||
|
||||
@ -21,7 +28,7 @@ const USER_COLUMNS = [
|
||||
|
||||
const USER_COLUMNS_PUBLIC = ['id', 'name', 'username', 'email', 'image_url'];
|
||||
|
||||
const emptify = value => {
|
||||
const emptify = (value) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
@ -37,14 +44,7 @@ const mapUserToColumns = (user: ICreateUser) => ({
|
||||
image_url: user.imageUrl,
|
||||
});
|
||||
|
||||
interface ICreateUser {
|
||||
name?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const rowToUser = row => {
|
||||
const rowToUser = (row) => {
|
||||
if (!row) {
|
||||
throw new NotFoundError('No user found');
|
||||
}
|
||||
@ -60,38 +60,19 @@ const rowToUser = row => {
|
||||
});
|
||||
};
|
||||
|
||||
export interface IUserLookup {
|
||||
id?: number;
|
||||
username?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface IUserSearch {
|
||||
name?: string;
|
||||
username?: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface IUserUpdateFields {
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
class UserStore {
|
||||
class UserStore implements IUserStore {
|
||||
private db: Knex;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(db: Knex, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('user-store.js');
|
||||
this.logger = getLogger('user-store.ts');
|
||||
}
|
||||
|
||||
async update(id: number, fields: IUserUpdateFields): Promise<User> {
|
||||
await this.db(TABLE)
|
||||
.where('id', id)
|
||||
.update(mapUserToColumns(fields));
|
||||
return this.get({ id });
|
||||
await this.db(TABLE).where('id', id).update(mapUserToColumns(fields));
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
async insert(user: ICreateUser): Promise<User> {
|
||||
@ -153,15 +134,13 @@ class UserStore {
|
||||
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);
|
||||
return rowToUser(row);
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
return this.db(TABLE)
|
||||
.where({ id })
|
||||
.del();
|
||||
return this.db(TABLE).where({ id }).del();
|
||||
}
|
||||
|
||||
async getPasswordHash(userId: number): Promise<string> {
|
||||
@ -177,11 +156,9 @@ class UserStore {
|
||||
}
|
||||
|
||||
async setPasswordHash(userId: number, passwordHash: string): Promise<void> {
|
||||
return this.db(TABLE)
|
||||
.where('id', userId)
|
||||
.update({
|
||||
password_hash: passwordHash,
|
||||
});
|
||||
return this.db(TABLE).where('id', userId).update({
|
||||
password_hash: passwordHash,
|
||||
});
|
||||
}
|
||||
|
||||
async incLoginAttempts(user: User): Promise<void> {
|
||||
@ -198,6 +175,22 @@ class UserStore {
|
||||
async deleteAll(): Promise<void> {
|
||||
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;
|
||||
|
@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
class InvalidOperationError extends Error {
|
||||
constructor(message: string) {
|
||||
super();
|
||||
|
@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
class NameExistsError extends Error {
|
||||
constructor(message: string) {
|
||||
super();
|
||||
|
@ -102,7 +102,7 @@ function baseTypeFor(event) {
|
||||
return event.type;
|
||||
}
|
||||
|
||||
const uniqueFieldForType = baseType => {
|
||||
const uniqueFieldForType = (baseType) => {
|
||||
if (baseType === 'user') {
|
||||
return 'id';
|
||||
}
|
||||
@ -112,7 +112,7 @@ const uniqueFieldForType = baseType => {
|
||||
function groupByBaseTypeAndName(events) {
|
||||
const groups = {};
|
||||
|
||||
events.forEach(event => {
|
||||
events.forEach((event) => {
|
||||
const baseType = baseTypeFor(event);
|
||||
const uniqueField = uniqueFieldForType(baseType);
|
||||
|
||||
@ -129,10 +129,10 @@ function groupByBaseTypeAndName(events) {
|
||||
function eachConsecutiveEvent(events, callback) {
|
||||
const groups = groupByBaseTypeAndName(events);
|
||||
|
||||
Object.keys(groups).forEach(baseType => {
|
||||
Object.keys(groups).forEach((baseType) => {
|
||||
const group = groups[baseType];
|
||||
|
||||
Object.keys(group).forEach(name => {
|
||||
Object.keys(group).forEach((name) => {
|
||||
const currentEvents = group[name];
|
||||
let left;
|
||||
let right;
|
||||
|
@ -22,7 +22,7 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
[FEATURE_CREATED, FEATURE_UPDATED, FEATURE_ARCHIVED, FEATURE_REVIVED].forEach(
|
||||
feature => {
|
||||
(feature) => {
|
||||
test(`should invoke hook on ${feature}`, () => {
|
||||
const data = { dataKey: feature };
|
||||
eventStore.emit(feature, data);
|
||||
|
@ -11,16 +11,16 @@ export const addEventHook = (
|
||||
eventHook: EventHook,
|
||||
eventStore: EventEmitter,
|
||||
): void => {
|
||||
eventStore.on(FEATURE_CREATED, data => {
|
||||
eventStore.on(FEATURE_CREATED, (data) => {
|
||||
eventHook(FEATURE_CREATED, data);
|
||||
});
|
||||
eventStore.on(FEATURE_UPDATED, data => {
|
||||
eventStore.on(FEATURE_UPDATED, (data) => {
|
||||
eventHook(FEATURE_UPDATED, data);
|
||||
});
|
||||
eventStore.on(FEATURE_ARCHIVED, data => {
|
||||
eventStore.on(FEATURE_ARCHIVED, (data) => {
|
||||
eventHook(FEATURE_ARCHIVED, data);
|
||||
});
|
||||
eventStore.on(FEATURE_REVIVED, data => {
|
||||
eventStore.on(FEATURE_REVIVED, (data) => {
|
||||
eventHook(FEATURE_REVIVED, data);
|
||||
});
|
||||
};
|
||||
|
@ -1,46 +1,36 @@
|
||||
'use strict';
|
||||
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
|
||||
const eventStore = new EventEmitter();
|
||||
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');
|
||||
import { register } from 'prom-client';
|
||||
import EventEmitter from 'events';
|
||||
import { createTestConfig } from '../test/config/test-config';
|
||||
import { REQUEST_TIME, DB_TIME } from './metric-events';
|
||||
import { FEATURE_UPDATED } from './types/events';
|
||||
import { createMetricsMonitor } from './metrics';
|
||||
import createStores from '../test/fixtures/store';
|
||||
|
||||
const monitor = createMetricsMonitor();
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
const prometheusRegister = register;
|
||||
let stores;
|
||||
beforeAll(() => {
|
||||
const featureToggleStore = {
|
||||
count: async () => 123,
|
||||
};
|
||||
const config = createTestConfig({
|
||||
server: {
|
||||
serverMetrics: true,
|
||||
},
|
||||
});
|
||||
const stores = {
|
||||
eventStore,
|
||||
clientMetricsStore,
|
||||
featureToggleStore,
|
||||
db: {
|
||||
client: {
|
||||
pool: {
|
||||
min: 0,
|
||||
max: 4,
|
||||
numUsed: () => 2,
|
||||
numFree: () => 2,
|
||||
numPendingAcquires: () => 0,
|
||||
numPendingCreates: () => 1,
|
||||
},
|
||||
stores = createStores();
|
||||
const db = {
|
||||
client: {
|
||||
pool: {
|
||||
min: 0,
|
||||
max: 4,
|
||||
numUsed: () => 2,
|
||||
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(() => {
|
||||
@ -57,12 +47,12 @@ test('should collect metrics for requests', async () => {
|
||||
|
||||
const metrics = await prometheusRegister.metrics();
|
||||
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 () => {
|
||||
eventStore.emit(FEATURE_UPDATED, {
|
||||
stores.eventStore.emit(FEATURE_UPDATED, {
|
||||
data: { name: 'TestToggle' },
|
||||
});
|
||||
|
||||
@ -73,7 +63,7 @@ test('should collect metrics for updated toggles', async () => {
|
||||
});
|
||||
|
||||
test('should collect metrics for client metric reports', async () => {
|
||||
clientMetricsStore.emit('metrics', {
|
||||
stores.clientMetricsStore.emit('metrics', {
|
||||
bucket: {
|
||||
toggles: {
|
||||
TestToggle: {
|
||||
@ -105,7 +95,7 @@ test('should collect metrics for db query timings', async () => {
|
||||
|
||||
test('should collect metrics for feature toggle size', async () => {
|
||||
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 () => {
|
@ -31,6 +31,7 @@ export default class MetricsMonitor {
|
||||
stores: IUnleashStores,
|
||||
version: string,
|
||||
eventBus: EventEmitter,
|
||||
db: Knex,
|
||||
): Promise<void> {
|
||||
if (!config.server.serverMetrics) {
|
||||
return;
|
||||
@ -72,7 +73,9 @@ export default class MetricsMonitor {
|
||||
featureTogglesTotal.reset();
|
||||
let togglesCount;
|
||||
try {
|
||||
togglesCount = await featureToggleStore.count();
|
||||
togglesCount = await featureToggleStore.count({
|
||||
archived: false,
|
||||
});
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {}
|
||||
|
||||
@ -110,7 +113,7 @@ export default class MetricsMonitor {
|
||||
featureToggleUpdateTotal.labels(data.name).inc();
|
||||
});
|
||||
|
||||
clientMetricsStore.on('metrics', m => {
|
||||
clientMetricsStore.on('metrics', (m) => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const entry of Object.entries(m.bucket.toggles)) {
|
||||
featureToggleUsageTotal
|
||||
@ -124,7 +127,7 @@ export default class MetricsMonitor {
|
||||
}
|
||||
});
|
||||
|
||||
this.configureDbMetrics(stores.db, eventBus);
|
||||
this.configureDbMetrics(db, eventBus);
|
||||
}
|
||||
|
||||
stopMonitoring(): void {
|
||||
@ -154,16 +157,14 @@ export default class MetricsMonitor {
|
||||
});
|
||||
const dbPoolPendingCreates = new client.Gauge({
|
||||
name: 'db_pool_pending_creates',
|
||||
help:
|
||||
'how many asynchronous create calls are running in DB pool',
|
||||
help: 'how many asynchronous create calls are running in DB pool',
|
||||
});
|
||||
const dbPoolPendingAcquires = new client.Gauge({
|
||||
name: 'db_pool_pending_acquires',
|
||||
help:
|
||||
'how many acquires are waiting for a resource to be released in DB pool',
|
||||
help: '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);
|
||||
dbPoolUsed.set(data.used);
|
||||
dbPoolPendingCreates.set(data.pendingCreates);
|
||||
|
@ -1,7 +1,9 @@
|
||||
const requireContentType = require('./content_type_checker');
|
||||
import { Request, Response } from 'express';
|
||||
import requireContentType from './content_type_checker';
|
||||
|
||||
const mockRequest = contentType => ({
|
||||
header: name => {
|
||||
const mockRequest: (contentType: string) => Request = (contentType) => ({
|
||||
// @ts-ignore
|
||||
header: (name) => {
|
||||
if (name === 'Content-Type') {
|
||||
return contentType;
|
||||
}
|
||||
@ -9,8 +11,9 @@ const mockRequest = contentType => ({
|
||||
},
|
||||
});
|
||||
|
||||
const returns415 = t => ({
|
||||
status: code => {
|
||||
const returns415: (t: jest.Mock) => Response = (t) => ({
|
||||
// @ts-ignore
|
||||
status: (code) => {
|
||||
expect(415).toBe(code);
|
||||
return {
|
||||
end: t,
|
||||
@ -18,7 +21,8 @@ const returns415 = t => ({
|
||||
},
|
||||
});
|
||||
|
||||
const expectNoCall = t => ({
|
||||
const expectNoCall: (t: jest.Mock) => Response = (t) => ({
|
||||
// @ts-ignore
|
||||
status: () => ({
|
||||
end: () => expect(t).toHaveBeenCalledTimes(0),
|
||||
}),
|
@ -1,3 +1,5 @@
|
||||
import { RequestHandler } from 'express';
|
||||
|
||||
const DEFAULT_ACCEPTED_CONTENT_TYPE = 'application/json';
|
||||
|
||||
/**
|
||||
@ -7,7 +9,9 @@ const DEFAULT_ACCEPTED_CONTENT_TYPE = 'application/json';
|
||||
* @param {String} acceptedContentTypes
|
||||
* @returns {function(Request, Response, NextFunction): void}
|
||||
*/
|
||||
function requireContentType(...acceptedContentTypes) {
|
||||
export default function requireContentType(
|
||||
...acceptedContentTypes: string[]
|
||||
): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const contentType = req.header('Content-Type');
|
||||
if (
|
@ -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) => {
|
||||
const { email } = req.body;
|
||||
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)
|
||||
// @ts-ignore
|
||||
.json(req.session.user)
|
||||
.end();
|
||||
});
|
||||
|
||||
app.use(`${basePath}/api/admin/`, (req, res, next) => {
|
||||
// @ts-ignore
|
||||
if (req.session.user && req.session.user.email) {
|
||||
// @ts-ignore
|
||||
req.user = req.session.user;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(`${basePath}/api/admin/`, (req, res, next) => {
|
||||
// @ts-ignore
|
||||
if (req.user) {
|
||||
return next();
|
||||
}
|
||||
return res
|
||||
.status('401')
|
||||
.status(401)
|
||||
.json(
|
||||
new AuthenticationRequired({
|
||||
path: `${basePath}/api/admin/login`,
|
||||
@ -34,5 +48,4 @@ function demoAuthentication(app, basePath = '', { userService }) {
|
||||
.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = demoAuthentication;
|
||||
export default demoAuthentication;
|
@ -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;
|
@ -1,28 +1,24 @@
|
||||
'use strict';
|
||||
|
||||
const supertest = require('supertest');
|
||||
const express = require('express');
|
||||
const noAuthentication = require('./no-authentication');
|
||||
import supertest from 'supertest';
|
||||
import express from 'express';
|
||||
import noAuthentication from './no-authentication';
|
||||
import { IUserRequest } from '../routes/admin-api/user';
|
||||
|
||||
test('should add dummy user object to all requests', () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const app = express();
|
||||
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 };
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json(user)
|
||||
.end();
|
||||
return res.status(200).json(user).end();
|
||||
});
|
||||
const request = supertest(app);
|
||||
|
||||
return request
|
||||
.get('/api/admin/test')
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
.expect((res) => {
|
||||
expect(res.body.username === 'unknown').toBe(true);
|
||||
});
|
||||
});
|
18
src/lib/middleware/no-authentication.ts
Normal file
18
src/lib/middleware/no-authentication.ts
Normal 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;
|
@ -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;
|
@ -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');
|
||||
const { EventEmitter } = require('events');
|
||||
const { createServices } = require('../services');
|
||||
const { createTestConfig } = require('../../test/config/test-config');
|
||||
|
||||
const store = require('../../test/fixtures/store');
|
||||
const ossAuth = require('./oss-authentication');
|
||||
const getApp = require('../app');
|
||||
const { User } = require('../server-impl');
|
||||
import createStores from '../../test/fixtures/store';
|
||||
import ossAuth from './oss-authentication';
|
||||
import getApp from '../app';
|
||||
import User from '../types/user';
|
||||
import sessionDb from './session-db';
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
|
||||
@ -16,19 +15,18 @@ function getSetup(preRouterHook) {
|
||||
const base = `/random${Math.round(Math.random() * 1000)}`;
|
||||
const config = createTestConfig({
|
||||
server: { baseUriPath: base },
|
||||
preRouterHook: _app => {
|
||||
preRouterHook: (_app) => {
|
||||
preRouterHook(_app);
|
||||
ossAuth(_app, { server: { baseUriPath: base } });
|
||||
ossAuth(_app, base);
|
||||
_app.get(`${base}/api/protectedResource`, (req, res) => {
|
||||
res.status(200)
|
||||
.json({ message: 'OK' })
|
||||
.end();
|
||||
res.status(200).json({ message: 'OK' }).end();
|
||||
});
|
||||
},
|
||||
});
|
||||
const stores = store.createStores();
|
||||
const stores = createStores();
|
||||
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 {
|
||||
base,
|
||||
@ -46,7 +44,7 @@ test('should return 401 when missing user', () => {
|
||||
test('should return 200 when user exists', () => {
|
||||
expect.assertions(0);
|
||||
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) => {
|
||||
req.user = user;
|
||||
next();
|
35
src/lib/middleware/oss-authentication.ts
Normal file
35
src/lib/middleware/oss-authentication.ts
Normal 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;
|
@ -1,16 +1,17 @@
|
||||
import rbacMiddleware from './rbac-middleware';
|
||||
import ffStore from '../../test/fixtures/fake-feature-toggle-store';
|
||||
import User from '../types/user';
|
||||
import * as perms from '../types/permissions';
|
||||
import { IUnleashConfig } from '../types/option';
|
||||
import { createTestConfig } from '../../test/config/test-config';
|
||||
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 featureToggleStore: any;
|
||||
let featureToggleStore: IFeatureToggleStore;
|
||||
|
||||
beforeEach(() => {
|
||||
featureToggleStore = ffStore();
|
||||
featureToggleStore = new FakeFeatureToggleStore();
|
||||
config = createTestConfig();
|
||||
});
|
||||
|
||||
@ -206,7 +207,6 @@ test('should lookup projectId from feature toggle', async () => {
|
||||
perms.UPDATE_FEATURE,
|
||||
projectId,
|
||||
);
|
||||
expect(featureToggleStore.getProjectId.mock.calls[0][0]).toBe(featureName);
|
||||
});
|
||||
|
||||
test('should lookup projectId from data', async () => {
|
||||
|
@ -1,8 +1,8 @@
|
||||
'use strict';
|
||||
import url from 'url';
|
||||
import { RequestHandler } from 'express';
|
||||
import { IUnleashConfig } from '../types/option';
|
||||
|
||||
const url = require('url');
|
||||
|
||||
module.exports = function(config) {
|
||||
const requestLogger: (config: IUnleashConfig) => RequestHandler = (config) => {
|
||||
const logger = config.getLogger('HTTP');
|
||||
const enable = config.server.enableRequestLogger;
|
||||
return (req, res, next) => {
|
||||
@ -15,3 +15,5 @@ module.exports = function(config) {
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
export default requestLogger;
|
@ -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) {
|
||||
return helmet({
|
||||
hsts: {
|
||||
@ -33,3 +35,5 @@ module.exports = function(config) {
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
export default secureHeaders;
|
@ -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 { IUnleashStores } from '../types/stores';
|
||||
|
||||
const session = require('express-session');
|
||||
const KnexSessionStore = require('connect-session-knex')(session);
|
||||
|
||||
const TWO_DAYS = 48 * 60 * 60 * 1000;
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
function sessionDb(
|
||||
config: Pick<IUnleashConfig, 'session' | 'server' | 'secureHeaders'>,
|
||||
stores: Pick<IUnleashStores, 'db'>,
|
||||
): any {
|
||||
knex: Knex,
|
||||
): RequestHandler {
|
||||
let store;
|
||||
const { db } = config.session;
|
||||
const age = config.session.ttlHours * HOUR || TWO_DAYS;
|
||||
const KnexSessionStore = knexSessionStore(session);
|
||||
if (db) {
|
||||
store = new KnexSessionStore({
|
||||
knex: stores.db,
|
||||
tablename: 'unleash_session',
|
||||
createtable: false,
|
||||
// @ts-ignore
|
||||
knex,
|
||||
});
|
||||
} else {
|
||||
store = new session.MemoryStore();
|
||||
}
|
||||
const sessionMiddleware = session({
|
||||
return session({
|
||||
name: 'unleash-session',
|
||||
rolling: false,
|
||||
resave: false,
|
||||
@ -38,7 +40,5 @@ function sessionDb(
|
||||
maxAge: age,
|
||||
},
|
||||
});
|
||||
return (req, res, next) => sessionMiddleware(req, res, next);
|
||||
}
|
||||
export default sessionDb;
|
||||
module.exports = sessionDb;
|
||||
|
@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import Controller from '../controller';
|
||||
import { IUnleashConfig } from '../../types/option';
|
||||
@ -9,7 +7,11 @@ import AddonService from '../../services/addon-service';
|
||||
|
||||
import extractUser from '../../extract-user';
|
||||
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 {
|
||||
private logger: Logger;
|
||||
@ -21,7 +23,7 @@ class AddonController extends Controller {
|
||||
{ addonService }: Pick<IUnleashServices, 'addonService'>,
|
||||
) {
|
||||
super(config);
|
||||
this.logger = config.getLogger('/admin-api/addon.js');
|
||||
this.logger = config.getLogger('/admin-api/addon.ts');
|
||||
this.addonService = addonService;
|
||||
|
||||
this.get('/', this.getAddons);
|
||||
@ -34,14 +36,17 @@ class AddonController extends Controller {
|
||||
async getAddons(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const addons = await this.addonService.getAddons();
|
||||
const providers = await this.addonService.getProviderDefinition();
|
||||
const providers = this.addonService.getProviderDefinitions();
|
||||
res.json({ addons, providers });
|
||||
} catch (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;
|
||||
try {
|
||||
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 createdBy = extractUser(req);
|
||||
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 username = extractUser(req);
|
||||
try {
|
||||
|
@ -9,11 +9,11 @@ import {
|
||||
} from '../../types/permissions';
|
||||
import { ApiTokenService } from '../../services/api-token-service';
|
||||
import { Logger } from '../../logger';
|
||||
import { ApiTokenType } from '../../db/api-token-store';
|
||||
import { AccessService } from '../../services/access-service';
|
||||
import { IAuthRequest } from '../unleash-types';
|
||||
import User from '../../types/user';
|
||||
import { IUnleashConfig } from '../../types/option';
|
||||
import { ApiTokenType } from '../../types/stores/api-token-store';
|
||||
|
||||
interface IServices {
|
||||
apiTokenService: ApiTokenService;
|
||||
@ -57,7 +57,7 @@ class ApiTokenController extends Controller {
|
||||
res.json({ tokens });
|
||||
} else {
|
||||
const filteredTokens = tokens.filter(
|
||||
t => !(t.type === ApiTokenType.ADMIN),
|
||||
(t) => !(t.type === ApiTokenType.ADMIN),
|
||||
);
|
||||
res.json({ tokens: filteredTokens });
|
||||
}
|
||||
|
@ -37,9 +37,8 @@ export default class ArchiveController extends Controller {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
async getArchivedFeatures(req, res): Promise<void> {
|
||||
try {
|
||||
const features = await this.featureService.getMetadataForAllFeatures(
|
||||
true,
|
||||
);
|
||||
const features =
|
||||
await this.featureService.getMetadataForAllFeatures(true);
|
||||
res.json({ version: 2, features });
|
||||
} catch (err) {
|
||||
handleErrors(res, this.logger, err);
|
||||
|
@ -3,15 +3,9 @@ import Controller from '../controller';
|
||||
import { AuthedRequest } from '../../types/core';
|
||||
import { Logger } from '../../logger';
|
||||
import ContextService from '../../services/context-service';
|
||||
import FeatureTypeStore, { IFeatureType } from '../../db/feature-type-store';
|
||||
import TagTypeService from '../../services/tag-type-service';
|
||||
import StrategyService from '../../services/strategy-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 { EmailService } from '../../services/email-service';
|
||||
import { IUnleashConfig } from '../../types/option';
|
||||
@ -19,6 +13,12 @@ import { IUnleashServices } from '../../types/services';
|
||||
import VersionService from '../../services/version-service';
|
||||
import FeatureTypeService from '../../services/feature-type-service';
|
||||
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 {
|
||||
private logger: Logger;
|
||||
|
@ -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');
|
||||
const { EventEmitter } = require('events');
|
||||
const { createServices } = require('../../services');
|
||||
const { createTestConfig } = require('../../../test/config/test-config');
|
||||
|
||||
const store = require('../../../test/fixtures/store');
|
||||
const getApp = require('../../app');
|
||||
import createStores from '../../../test/fixtures/store';
|
||||
import getApp from '../../app';
|
||||
import { createServices } from '../../services';
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
|
||||
@ -21,7 +19,7 @@ function getSetup() {
|
||||
server: { baseUriPath: base },
|
||||
ui: uiConfig,
|
||||
});
|
||||
const stores = store.createStores();
|
||||
const stores = createStores();
|
||||
const services = createServices(stores, config);
|
||||
|
||||
const app = getApp(config, stores, services, eventBus);
|
||||
@ -57,7 +55,7 @@ test('should get ui config', () => {
|
||||
.get(`${base}/api/admin/ui-config`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
.expect((res) => {
|
||||
expect(res.body.slogan === 'hello').toBe(true);
|
||||
expect(res.body.headerBackground === 'red').toBe(true);
|
||||
});
|
@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { IUnleashServices } from '../../types/services';
|
||||
import { IUnleashConfig } from '../../types/option';
|
||||
|
@ -1,12 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const supertest = require('supertest');
|
||||
const { EventEmitter } = require('events');
|
||||
const { createTestConfig } = require('../../../test/config/test-config');
|
||||
const store = require('../../../test/fixtures/store');
|
||||
const { createServices } = require('../../services');
|
||||
const permissions = require('../../../test/fixtures/permissions');
|
||||
const getApp = require('../../app');
|
||||
import supertest from 'supertest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { createTestConfig } from '../../../test/config/test-config';
|
||||
import createStores from '../../../test/fixtures/store';
|
||||
import { createServices } from '../../services';
|
||||
import permissions from '../../../test/fixtures/permissions';
|
||||
import getApp from '../../app';
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
|
||||
@ -17,7 +15,7 @@ function getSetup() {
|
||||
preHook: perms.hook,
|
||||
server: { baseUriPath: base },
|
||||
});
|
||||
const stores = store.createStores();
|
||||
const stores = createStores();
|
||||
|
||||
const services = createServices(stores, config);
|
||||
const app = getApp(config, stores, services, eventBus);
|
||||
@ -54,9 +52,9 @@ test('should get all context definitions', () => {
|
||||
.get(`${base}/api/admin/context`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
.expect((res) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -67,7 +65,7 @@ test('should get context definition', () => {
|
||||
.get(`${base}/api/admin/context/userId`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
.expect((res) => {
|
||||
expect(res.body.name).toBe('userId');
|
||||
});
|
||||
});
|
@ -47,9 +47,7 @@ class ContextController extends Controller {
|
||||
async getContextFields(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const fields = await this.contextService.getAll();
|
||||
res.status(200)
|
||||
.json(fields)
|
||||
.end();
|
||||
res.status(200).json(fields).end();
|
||||
} catch (e) {
|
||||
handleErrors(res, this.logger, e);
|
||||
}
|
||||
|
@ -1,18 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const supertest = require('supertest');
|
||||
const { EventEmitter } = require('events');
|
||||
const { createTestConfig } = require('../../../test/config/test-config');
|
||||
const store = require('../../../test/fixtures/store');
|
||||
const { createServices } = require('../../services');
|
||||
const permissions = require('../../../test/fixtures/permissions');
|
||||
const getApp = require('../../app');
|
||||
import supertest from 'supertest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { createTestConfig } from '../../../test/config/test-config';
|
||||
import createStores from '../../../test/fixtures/store';
|
||||
import { createServices } from '../../services';
|
||||
import permissions from '../../../test/fixtures/permissions';
|
||||
import getApp from '../../app';
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
|
||||
function getSetup() {
|
||||
const base = `/random${Math.round(Math.random() * 1000)}`;
|
||||
const stores = store.createStores();
|
||||
const stores = createStores();
|
||||
const perms = permissions();
|
||||
const config = createTestConfig({
|
||||
preHook: perms.hook,
|
||||
@ -37,7 +35,7 @@ test('should render html preview of template', () => {
|
||||
)
|
||||
.expect('Content-Type', /html/)
|
||||
.expect(200)
|
||||
.expect(res => 'Test Test' in res.body);
|
||||
.expect((res) => 'Test Test' in res.body);
|
||||
});
|
||||
|
||||
test('should render text preview of template', () => {
|
||||
@ -49,7 +47,7 @@ test('should render text preview of template', () => {
|
||||
)
|
||||
.expect('Content-Type', /plain/)
|
||||
.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', () => {
|
@ -1,5 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
import { handleErrors } from './util';
|
||||
import { IUnleashConfig } from '../../types/option';
|
||||
import { IUnleashServices } from '../../types/services';
|
||||
|
@ -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');
|
||||
const { EventEmitter } = require('events');
|
||||
const { createServices } = require('../../services');
|
||||
const { createTestConfig } = require('../../../test/config/test-config');
|
||||
import createStores from '../../../test/fixtures/store';
|
||||
|
||||
const store = require('../../../test/fixtures/store');
|
||||
|
||||
const getApp = require('../../app');
|
||||
import getApp from '../../app';
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
|
||||
function getSetup() {
|
||||
const base = `/random${Math.round(Math.random() * 1000)}`;
|
||||
const stores = store.createStores();
|
||||
const stores = createStores();
|
||||
const config = createTestConfig({
|
||||
server: { baseUriPath: base },
|
||||
});
|
||||
@ -30,7 +28,7 @@ test('should get empty events list via admin', () => {
|
||||
.get(`${base}/api/admin/events`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
.expect((res) => {
|
||||
expect(res.body.events.length === 0).toBe(true);
|
||||
});
|
||||
});
|
@ -1,5 +1,4 @@
|
||||
'use strict';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { handleErrors } from './util';
|
||||
import { IUnleashServices } from '../../types/services';
|
||||
import FeatureTypeService from '../../services/feature-type-service';
|
||||
@ -26,7 +25,7 @@ export default class FeatureTypeController extends Controller {
|
||||
this.get('/', this.getAllFeatureTypes);
|
||||
}
|
||||
|
||||
async getAllFeatureTypes(req, res) {
|
||||
async getAllFeatureTypes(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const types = await this.featureTypeService.getAll();
|
||||
res.json({ version, types });
|
||||
|
@ -34,8 +34,8 @@ class FeatureController extends Controller {
|
||||
featureTagService,
|
||||
featureToggleServiceV2,
|
||||
}: Pick<
|
||||
IUnleashServices,
|
||||
'featureTagService' | 'featureToggleServiceV2'
|
||||
IUnleashServices,
|
||||
'featureTagService' | 'featureToggleServiceV2'
|
||||
>,
|
||||
) {
|
||||
super(config);
|
||||
@ -87,7 +87,7 @@ class FeatureController extends Controller {
|
||||
namePrefix,
|
||||
});
|
||||
if (query.tag) {
|
||||
query.tag = query.tag.map(q => q.split(':'));
|
||||
query.tag = query.tag.map((q) => q.split(':'));
|
||||
}
|
||||
return query;
|
||||
}
|
||||
@ -113,7 +113,7 @@ class FeatureController extends Controller {
|
||||
const name = req.params.featureName;
|
||||
const feature = await this.featureService2.getFeatureToggle(name);
|
||||
const strategies =
|
||||
feature.environments.find(e => e.name === GLOBAL_ENV)
|
||||
feature.environments.find((e) => e.name === GLOBAL_ENV)
|
||||
?.strategies || [];
|
||||
res.json({
|
||||
...feature,
|
||||
@ -186,13 +186,14 @@ class FeatureController extends Controller {
|
||||
try {
|
||||
const validatedToggle = await featureSchema.validateAsync(toggle);
|
||||
const { enabled } = validatedToggle;
|
||||
const createdFeature = await this.featureService2.createFeatureToggle(
|
||||
validatedToggle.project,
|
||||
validatedToggle,
|
||||
userName,
|
||||
);
|
||||
const createdFeature =
|
||||
await this.featureService2.createFeatureToggle(
|
||||
validatedToggle.project,
|
||||
validatedToggle,
|
||||
userName,
|
||||
);
|
||||
const strategies = await Promise.all(
|
||||
toggle.strategies.map(async s =>
|
||||
toggle.strategies.map(async (s) =>
|
||||
this.featureService2.createStrategy(
|
||||
s,
|
||||
createdFeature.project,
|
||||
@ -248,7 +249,7 @@ class FeatureController extends Controller {
|
||||
let strategies;
|
||||
if (updatedFeature.strategies) {
|
||||
strategies = await Promise.all(
|
||||
updatedFeature.strategies.map(async s =>
|
||||
updatedFeature.strategies.map(async (s) =>
|
||||
this.featureService2.createStrategy(
|
||||
s,
|
||||
projectId,
|
||||
|
@ -1,17 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
const supertest = require('supertest');
|
||||
const { EventEmitter } = require('events');
|
||||
const store = require('../../../test/fixtures/store');
|
||||
const permissions = require('../../../test/fixtures/permissions');
|
||||
const getApp = require('../../app');
|
||||
const { createTestConfig } = require('../../../test/config/test-config');
|
||||
const { createServices } = require('../../services');
|
||||
import supertest from 'supertest';
|
||||
import { EventEmitter } from 'events';
|
||||
import createStores from '../../../test/fixtures/store';
|
||||
import permissions from '../../../test/fixtures/permissions';
|
||||
import getApp from '../../app';
|
||||
import { createTestConfig } from '../../../test/config/test-config';
|
||||
import { createServices } from '../../services';
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
|
||||
function getSetup() {
|
||||
const stores = store.createStores();
|
||||
const stores = createStores();
|
||||
const perms = permissions();
|
||||
const config = createTestConfig({
|
||||
preRouterHook: perms.hook,
|
||||
@ -51,7 +49,7 @@ test('should return seen toggles even when there is nothing', () => {
|
||||
return request
|
||||
.get('/api/admin/metrics/seen-toggles')
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
.expect((res) => {
|
||||
expect(res.body.length === 0).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -75,7 +73,7 @@ test('should return list of seen-toggles per app', () => {
|
||||
return request
|
||||
.get('/api/admin/metrics/seen-toggles')
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
.expect((res) => {
|
||||
const seenAppsWithToggles = res.body;
|
||||
expect(seenAppsWithToggles.length === 1).toBe(true);
|
||||
expect(seenAppsWithToggles[0].appName === appName).toBe(true);
|
||||
@ -107,7 +105,7 @@ test('should return metrics for all toggles', () => {
|
||||
return request
|
||||
.get('/api/admin/metrics/feature-toggles')
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
.expect((res) => {
|
||||
const metrics = res.body;
|
||||
expect(metrics.lastHour !== undefined).toBe(true);
|
||||
expect(metrics.lastMinute !== undefined).toBe(true);
|
||||
@ -120,7 +118,7 @@ test('should return empty list of client applications', () => {
|
||||
return request
|
||||
.get('/api/admin/metrics/applications')
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
.expect((res) => {
|
||||
expect(res.body.applications.length === 0).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -134,7 +132,7 @@ test('should return applications', () => {
|
||||
return request
|
||||
.get('/api/admin/metrics/applications/')
|
||||
.expect(200)
|
||||
.expect(res => {
|
||||
.expect((res) => {
|
||||
const metrics = res.body;
|
||||
expect(metrics.applications.length === 1).toBe(true);
|
||||
expect(metrics.applications[0].appName === appName).toBe(true);
|
@ -19,7 +19,7 @@ class MetricsController extends Controller {
|
||||
}: Pick<IUnleashServices, 'clientMetricsService'>,
|
||||
) {
|
||||
super(config);
|
||||
this.logger = config.getLogger('/admin-api/metrics.js');
|
||||
this.logger = config.getLogger('/admin-api/metrics.ts');
|
||||
|
||||
this.metrics = clientMetricsService;
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { IUnleashServices } from '../../../types/services';
|
||||
import { Logger } from '../../../logger';
|
||||
import EnvironmentService from '../../../services/environment-service';
|
||||
import { handleErrors } from '../util';
|
||||
import { UPDATE_FEATURE, UPDATE_PROJECT } from '../../../types/permissions';
|
||||
import { UPDATE_PROJECT } from '../../../types/permissions';
|
||||
|
||||
const PREFIX = '/:projectId/environments';
|
||||
|
||||
@ -41,10 +41,10 @@ export default class EnvironmentsController extends Controller {
|
||||
|
||||
async addEnvironmentToProject(
|
||||
req: Request<
|
||||
Omit<IProjectEnvironmentParams, 'environment'>,
|
||||
any,
|
||||
EnvironmentBody,
|
||||
any
|
||||
Omit<IProjectEnvironmentParams, 'environment'>,
|
||||
any,
|
||||
EnvironmentBody,
|
||||
any
|
||||
>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user