1
0
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:
Christopher Kolstad 2021-08-12 15:04:37 +02:00 committed by GitHub
parent c5b8b2f920
commit ff7be7696c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
381 changed files with 7458 additions and 5121 deletions

View File

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

View File

@ -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/**"]

View File

@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
const joi = require('joi');
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,
};

View File

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

View File

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

View File

@ -1,10 +1,20 @@
'use strict';
import fetch, { Response } from 'node-fetch';
import { addonDefinitionSchema } from './addon-schema';
import { IUnleashConfig } from '../types/option';
import { Logger } from '../logger';
import { IAddonDefinition, IEvent } from '../types/model';
const fetch = require('node-fetch');
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>;
}

View File

@ -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;

View File

@ -1,31 +1,44 @@
const { FEATURE_CREATED, FEATURE_ARCHIVED } = require('../types/events');
import { FEATURE_CREATED, FEATURE_ARCHIVED } from '../types/events';
import { Logger } from '../logger';
import DatadogAddon from './datadog';
import noLogger from '../../test/fixtures/no-logger';
import { IEvent } from '../types/model';
let fetchRetryCalls: any[] = [];
jest.mock(
'./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();
});

View File

@ -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;

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,14 @@
'use strict';
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;

View File

@ -1,31 +1,44 @@
const { FEATURE_CREATED, FEATURE_ARCHIVED } = require('../types/events');
import { FEATURE_CREATED, FEATURE_ARCHIVED } from '../types/events';
import { Logger } from '../logger';
import SlackAddon from './slack';
import noLogger from '../../test/fixtures/no-logger';
import { IEvent } from '../types/model';
let fetchRetryCalls: any[] = [];
jest.mock(
'./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');
});

View File

@ -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';

View File

@ -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;

View File

@ -1,31 +1,45 @@
const { FEATURE_CREATED, FEATURE_ARCHIVED } = require('../types/events');
import { Logger } from '../logger';
import { FEATURE_CREATED, FEATURE_ARCHIVED } from '../types/events';
import TeamsAddon from './teams';
import noLogger from '../../test/fixtures/no-logger';
import { IEvent } from '../types/model';
let fetchRetryCalls: any[];
jest.mock(
'./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();
});

View File

@ -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;

View File

@ -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;

View File

@ -1,28 +1,42 @@
const { FEATURE_CREATED } = require('../types/events');
import { Logger } from '../logger';
import { FEATURE_CREATED } from '../types/events';
import WebhookAddon from './webhook';
import noLogger from '../../test/fixtures/no-logger';
import { IEvent } from '../types/model';
let fetchRetryCalls: any[] = [];
jest.mock(
'./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');

View File

@ -1,15 +1,16 @@
'use strict';
import Mustache from 'mustache';
import Addon from './addon';
import definition from './webhook-definition';
import { LogProvider } from '../logger';
import { IEvent } from '../types/model';
const Mustache = require('mustache');
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;

View File

@ -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: {

View File

@ -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 = {

View File

@ -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,
}));

View File

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

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

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

View File

@ -4,6 +4,12 @@ import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import { 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> {

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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 {

View File

@ -23,7 +23,7 @@ function getMockDb() {
};
}
test('should call database on startup', done => {
test('should call database on startup', (done) => {
jest.useFakeTimers('modern');
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);

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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),
},
});
}

View File

@ -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 {}
}

View File

@ -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);
});

View File

@ -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 || [],
};
}

View File

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

View File

@ -4,18 +4,16 @@ import * as uuid from 'uuid';
import metricsHelper from '../util/metrics-helper';
import { 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;

View File

@ -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();
}

View File

@ -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;

View File

@ -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;

View File

@ -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,
),
};
};

View File

@ -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');

View File

@ -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);
}
}

View File

@ -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,

View File

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

View File

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

View File

@ -2,6 +2,12 @@ import { Knex } from 'knex';
import { Logger, LogProvider } from '../logger';
import 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),
);
}

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

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

View File

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

View File

@ -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;

View File

@ -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);

View File

@ -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);
});
};

View File

@ -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 () => {

View File

@ -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);

View File

@ -1,7 +1,9 @@
const requireContentType = require('./content_type_checker');
import { Request, Response } from 'express';
import requireContentType from './content_type_checker';
const mockRequest = contentType => ({
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),
}),

View File

@ -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 (

View File

@ -1,28 +1,42 @@
const AuthenticationRequired = require('../types/authentication-required');
import { Application } from 'express';
import AuthenticationRequired from '../types/authentication-required';
import { IUnleashServices } from '../types/services';
function demoAuthentication(app, basePath = '', { userService }) {
function demoAuthentication(
app: Application,
basePath: string = '',
{ userService }: Pick<IUnleashServices, 'userService'>,
): void {
app.post(`${basePath}/api/admin/login`, async (req, res) => {
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;

View File

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

View File

@ -1,28 +1,24 @@
'use strict';
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);
});
});

View File

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

View File

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

View File

@ -1,14 +1,13 @@
'use strict';
import supertest from 'supertest';
import { EventEmitter } from 'events';
import { createServices } from '../services';
import { createTestConfig } from '../../test/config/test-config';
const supertest = require('supertest');
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();

View File

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

View File

@ -1,16 +1,17 @@
import rbacMiddleware from './rbac-middleware';
import 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 () => {

View File

@ -1,8 +1,8 @@
'use strict';
import url from 'url';
import { RequestHandler } from 'express';
import { IUnleashConfig } from '../types/option';
const url = require('url');
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;

View File

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

View File

@ -1,28 +1,30 @@
import { Knex } from 'knex';
import session from 'express-session';
import knexSessionStore from 'connect-session-knex';
import { RequestHandler } from 'express';
import { IUnleashConfig } from '../types/option';
import { 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;

View File

@ -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 {

View File

@ -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 });
}

View File

@ -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);

View File

@ -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;

View File

@ -1,12 +1,10 @@
'use strict';
import supertest from 'supertest';
import { EventEmitter } from 'events';
import { createTestConfig } from '../../../test/config/test-config';
const supertest = require('supertest');
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);
});

View File

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

View File

@ -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');
});
});

View File

@ -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);
}

View File

@ -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', () => {

View File

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

View File

@ -1,19 +1,17 @@
'use strict';
import supertest from 'supertest';
import { EventEmitter } from 'events';
import { createServices } from '../../services';
import { createTestConfig } from '../../../test/config/test-config';
const supertest = require('supertest');
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);
});
});

View File

@ -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 });

View File

@ -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,

View File

@ -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);

View File

@ -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;

View File

@ -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