1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

feat: Introduce addon framework

fixes: #587

Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
This commit is contained in:
Ivar Conradi Østhus 2021-01-19 10:42:45 +01:00
parent 8f99f71156
commit 17c8fe7710
42 changed files with 2505 additions and 26 deletions

View File

@ -4,7 +4,7 @@
},
"extends": ["airbnb-base", "prettier"],
"parserOptions": {
"ecmaVersion": "2018"
"ecmaVersion": 2019
},
"plugins": ["prettier"],
"root": true,

8
docs/addons.md Normal file
View File

@ -0,0 +1,8 @@
---
id: addons
title: Unleash Addons
---
> This feature was introduced in _Unleash v3.11.0_.
Unleash Addons allows you to extend Unleash with new functionality

View File

@ -0,0 +1,21 @@
---
id: jira-comment
title: Unleash Jira commenter addon
---
> This feature was introduced in _Unleash v3.11.0_.
Enables commenting on issues from unleash when toggles are updated/revived/archived/created.
## Configuration
- When configuring an instance of the Jira commenter plugin you'll need the following
- JIRA base url - e.g. https://mycompany.atlassian.net.
- JIRA username - the username of the user the plugin should comment as.
- JIRA api token - an api token for the user the plugin should comment as.
- If you're running Atlassian cloud - it can be added by visiting: https://id.atlassian.com/manage-profile/security/api-tokens when logged in as the user the plugin should comment as.
- After the instance is configured, you can now tag your feature toggles with tags of type `jira`.
- The value of the tag should be in normal JIRA issue format (PROJECTKEY-ISSUENUMBER).
- Once a toggle has been tagged with a jira tag, all updates to the toggle will be added as a comment on the issue stored in the tag.

8
docs/addons/webhook.md Normal file
View File

@ -0,0 +1,8 @@
---
id: webhook
title: Unleash Webhook addon
---
> This feature was introduced in _Unleash v3.11.0_.
Unleash Addons allows you to extend Unleash with new functionality

157
docs/api/admin/addons.md Normal file
View File

@ -0,0 +1,157 @@
---
id: addons
title: /api/admin/addons
---
> In order to access the admin API endpoints you need to identify yourself. If you are using the `insecure` authentication method, you may use [basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) to identify yourself.
### List addons and providers
`GET https://unleash.host.com/api/admin/addons`
Returns a list of _configured addons_ and available _addon providers_.
**Example response:**
```json
{
"addons": [
{
"id": 30,
"provider": "webhook",
"enabled": true,
"description": "post updates to slack",
"parameters": {
"url": "http://localhost:4242/webhook"
},
"events": ["feature-created", "feature-updated"]
},
{
"id": 33,
"provider": "slack",
"enabled": true,
"description": "default",
"parameters": {
"defaultChannel": "integration-demo-instance",
"url": "https://hooks.slack.com/someurl"
},
"events": ["feature-created", "feature-updated"]
}
],
"providers": [
{
"name": "webhook",
"displayName": "Webhook",
"description": "Webhooks are a simple way to post messages from Unleash to third party services. Unleash make use of normal HTTP POST with a payload you may define yourself.",
"parameters": [
{
"name": "url",
"displayName": "Webhook URL",
"type": "url",
"required": true
},
{
"name": "bodyTemplate",
"displayName": "Body template",
"description": "You may format the body using a mustache template. If you don't specify anything, the format will be similar to the /api/admin/events format",
"type": "textfield",
"required": false
}
],
"events": [
"feature-created",
"feature-updated",
"feature-archived",
"feature-revived"
]
},
{
"name": "slack",
"displayName": "Slack",
"description": "Integrates Unleash with Slack.",
"parameters": [
{
"name": "url",
"displayName": "Slack webhook URL",
"type": "url",
"required": true
},
{
"name": "defaultChannel",
"displayName": "Default channel",
"description": "Default channel to post updates to if not specified in the slack-tag",
"type": "text",
"required": true
}
],
"events": [
"feature-created",
"feature-updated",
"feature-archived",
"feature-revived"
],
"tags": [
{
"name": "slack",
"description": "Slack tag used by the slack-addon to specify the slack channel.",
"icon": "S"
}
]
}
]
}
```
### Create a new addon configuration
`POST https://unleash.host.com/api/addons`
Creates an addon configuration for an addon provider.
**Body**
```json
{
"provider": "webhook",
"description": "Optional description",
"enabled": true,
"parameters": {
"url": "http://localhost:4242/webhook"
},
"events": ["feature-created", "feature-updated"]
}
```
### Notes
- `provider` must be a valid addon provider
### Update new addon configuration
`POST https://unleash.host.com/api/addons/:id`
Updates an addon configuration.
**Body**
```json
{
"provider": "webhook",
"description": "Optional updated description",
"enabled": true,
"parameters": {
"url": "http://localhost:4242/webhook"
},
"events": ["feature-created", "feature-updated"]
}
```
### Notes
- `provider` can not be changed.
### Delete an addon configuration
`DELETE https://unleash.host.com/api/admin/addons/:id`
Deletes the addon with id=`id`.

View File

@ -0,0 +1,35 @@
const joi = require('joi');
const { nameType } = require('../routes/admin-api/util');
const { tagTypeSchema } = require('../services/tag-type-schema');
const addonDefinitionSchema = joi.object().keys({
name: nameType,
displayName: joi.string(),
documentationUrl: joi.string().uri({ scheme: [/https?/] }),
description: joi.string().allow(''),
parameters: joi
.array()
.optional()
.items(
joi.object().keys({
name: joi.string().required(),
displayName: joi.string().required(),
type: joi.string().required(),
description: joi.string(),
placeholder: joi.string().allow(''),
required: joi.boolean().default(false),
}),
),
events: joi
.array()
.optional()
.items(joi.string()),
tagTypes: joi
.array()
.optional()
.items(tagTypeSchema),
});
module.exports = {
addonDefinitionSchema,
};

44
lib/addons/addon.js Normal file
View File

@ -0,0 +1,44 @@
'use strict';
const fetch = require('node-fetch');
const { addonDefinitionSchema } = require('./addon-schema');
class Addon {
constructor(definition, { getLogger }) {
this.logger = getLogger(`addon/${definition.name}`);
const { error } = addonDefinitionSchema.validate(definition);
if (error) {
this.logger.warn(
`Could not load addon provider ${definition.name}`,
error,
);
throw error;
}
this._name = definition.name;
this._definition = definition;
}
get name() {
return this._name;
}
get definition() {
return this._definition;
}
async fetchRetry(url, options = {}, retries = 1, backoff = 300) {
const retryCodes = [408, 500, 502, 503, 504, 522, 524];
const res = await fetch(url, options);
if (res.ok) {
return res;
}
if (retries > 0 && retryCodes.includes(res.status)) {
setTimeout(() => {
return this.fetchRetry(url, options, retries - 1, backoff * 2);
}, backoff);
}
return res;
}
}
module.exports = Addon;

5
lib/addons/index.js Normal file
View File

@ -0,0 +1,5 @@
const webhook = require('./webhook');
const slackAddon = require('./slack');
const jiraAddon = require('./jira-comment');
module.exports = [webhook, slackAddon, jiraAddon];

View File

@ -0,0 +1,51 @@
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
} = require('../event-type');
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,
},
{
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',
},
],
};

116
lib/addons/jira-comment.js Normal file
View File

@ -0,0 +1,116 @@
'use strict';
const Addon = require('./addon');
const definition = require('./jira-comment-definition');
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_REVIVED,
FEATURE_ARCHIVED,
} = require('../event-type');
class JiraAddon extends Addon {
constructor(args) {
super(definition, args);
this.unleashUrl = args.unleashUrl || 'http://localhost:4242';
}
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

@ -0,0 +1,230 @@
const test = require('ava');
const proxyquire = require('proxyquire');
const fetchMock = require('fetch-mock').sandbox();
const lolex = require('lolex');
const noLogger = require('../../test/fixtures/no-logger');
const addonMocked = proxyquire('./addon', { 'node-fetch': fetchMock });
const JiraAddon = proxyquire('./jira-comment', { './addon': addonMocked });
const { addonDefinitionSchema } = require('./addon-schema');
test.beforeEach(() => {
fetchMock.restore();
fetchMock.reset();
});
test('Addon definition should validate', t => {
const { error } = addonDefinitionSchema.validate(JiraAddon.definition);
t.is(error, undefined);
});
test.serial(
'An update event should post updated comment with updater and link back to issue',
async t => {
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',
},
);
t.is(fetchMock.calls(true).length, 1);
t.true(fetchMock.done());
},
);
test.serial(
'An event that is tagged with two tags causes two updates',
async t => {
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',
},
);
t.true(fetchMock.done(), 'All routes should be matched');
},
);
test.serial('An event with no jira tags will be ignored', async t => {
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',
},
);
t.is(fetchMock.calls().length, 0); // No calls
});
test.serial('Retries if error code in the 500s', async t => {
const jiraBaseUrl = 'https://test.jira.com';
const jiraIssue = 'TEST-1';
const clock = lolex.install();
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',
},
);
clock.tick(1000);
t.true(fetchMock.done());
});
test.serial('Only retries once', async t => {
const jiraBaseUrl = 'https://test.jira.com';
const jiraIssue = 'TEST-1';
const clock = lolex.install();
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',
},
);
clock.tick(1000);
t.true(fetchMock.done());
});
test.serial('Does not retry if a 4xx error is given', async t => {
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',
},
);
t.true(fetchMock.done());
});

View File

@ -0,0 +1,63 @@
'use strict';
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
} = require('../event-type');
module.exports = {
name: 'slack',
displayName: 'Slack',
description: 'Allows Unleash to post updates to Slack.',
documentationUrl: 'https://unleash.github.io/docs/addons/slack',
parameters: [
{
name: 'url',
displayName: 'Slack webhook URL',
type: 'url',
required: true,
},
{
name: 'username',
displayName: 'Username',
placeholder: 'Unleash',
description:
'The username to use when posting messages to slack. Defaults to "Unleash".',
type: 'text',
required: false,
},
{
name: 'emojiIcon',
displayName: 'Emoji Icon',
placeholder: ':unleash:',
description:
'The emoji_icon to use when posting messages to slack. Defaults to ":unleash:".',
type: 'text',
required: false,
},
{
name: 'defaultChannel',
displayName: 'Default channel',
description:
'Default channel to post updates to if not specified in the slack-tag',
type: 'text',
required: true,
},
],
events: [
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
],
tagTypes: [
{
name: 'slack',
description:
'Slack tag used by the slack-addon to specify the slack channel.',
icon: 'S',
},
],
};

109
lib/addons/slack.js Normal file
View File

@ -0,0 +1,109 @@
'use strict';
const YAML = require('js-yaml');
const Addon = require('./addon');
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
} = require('../event-type');
const definition = require('./slack-definition');
class SlackAddon extends Addon {
constructor(args) {
super(definition, args);
this.unleashUrl = args.unleashUrl || 'http://localhost:4242';
}
async handleEvent(event, parameters) {
const {
url,
defaultChannel,
username = 'Unleash',
iconEmoji = ':unleash:',
} = parameters;
const slackChannels = this.findSlackChannels(event);
if (slackChannels.length === 0) {
slackChannels.push(defaultChannel);
}
const text = this.generateText(event);
const requests = slackChannels.map(channel => {
const body = {
username,
icon_emoji: iconEmoji, // eslint-disable-line camelcase
text,
channel: `#${channel}`,
attachments: [
{
actions: [
{
name: 'featureToggle',
text: 'Open in Unleash',
type: 'button',
value: 'featureToggle',
style: 'primary',
url: `${this.unleashUrl}/#/features/strategies/${event.data.name}`,
},
],
},
],
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
};
return this.fetchRetry(url, 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}`);
}
findSlackChannels({ tags = [] }) {
return tags.filter(tag => tag.type === 'slack').map(t => t.value);
}
generateText({ createdBy, data, type }) {
const eventName = this.eventName(type);
const feature = `<${this.unleashUrl}/#/features/strategies/${data.name}|${data.name}>`;
const enabled = `*Enabled*: ${data.enabled ? 'yes' : 'no'}`;
const stale = data.stale ? '("stale")' : '';
const typeStr = `*Type*: ${data.type}`;
const project = `*Project*: ${data.project}`;
const strategies = `*Activation strategies*: \`\`\`${YAML.safeDump(
data.strategies,
{ skipInvalid: true },
)}\`\`\``;
return `${createdBy} ${eventName} ${feature}
${enabled}${stale} | ${typeStr} | ${project}
${strategies}`;
}
eventName(type) {
switch (type) {
case FEATURE_CREATED:
return 'created feature toggle';
case FEATURE_UPDATED:
return 'updated feature toggle';
case FEATURE_ARCHIVED:
return 'archived feature toggle';
case FEATURE_REVIVED:
return 'revive feature toggle';
default:
return type;
}
}
}
module.exports = SlackAddon;

132
lib/addons/slack.test.js Normal file
View File

@ -0,0 +1,132 @@
const test = require('ava');
const proxyquire = require('proxyquire').noCallThru();
const { FEATURE_CREATED } = require('../event-type');
const WebhookAddon = proxyquire.load('./slack', {
'./addon': class Addon {
constructor(definition, { getLogger }) {
this.logger = getLogger('addon/test');
this.fetchRetryCalls = [];
}
async fetchRetry(url, options, retries, backoff) {
this.fetchRetryCalls.push({ url, options, retries, backoff });
return Promise.resolve({ status: 200 });
}
},
});
const noLogger = require('../../test/fixtures/no-logger');
test('Should call slack webhook', async t => {
const addon = new WebhookAddon({ getLogger: noLogger });
const event = {
type: FEATURE_CREATED,
createdBy: 'some@user.com',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://hooks.slack.com',
};
await addon.handleEvent(event, parameters);
t.is(addon.fetchRetryCalls.length, 1);
t.is(addon.fetchRetryCalls[0].url, parameters.url);
t.snapshot(addon.fetchRetryCalls[0].options.body);
});
test('Should use default channel', async t => {
const addon = new WebhookAddon({ getLogger: noLogger });
const event = {
type: FEATURE_CREATED,
createdBy: 'some@user.com',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://hooks.slack.com',
defaultChannel: 'some-channel',
};
await addon.handleEvent(event, parameters);
const req = JSON.parse(addon.fetchRetryCalls[0].options.body);
t.is(req.channel, '#some-channel');
});
test('Should override default channel with data from tag', async t => {
const addon = new WebhookAddon({ getLogger: noLogger });
const event = {
type: FEATURE_CREATED,
createdBy: 'some@user.com',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
tags: [
{
type: 'slack',
value: 'another-channel',
},
],
};
const parameters = {
url: 'http://hooks.slack.com',
defaultChannel: 'some-channel',
};
await addon.handleEvent(event, parameters);
const req = JSON.parse(addon.fetchRetryCalls[0].options.body);
t.is(req.channel, '#another-channel');
});
test('Should post to all channels in tags', async t => {
const addon = new WebhookAddon({ getLogger: noLogger });
const event = {
type: FEATURE_CREATED,
createdBy: 'some@user.com',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
tags: [
{
type: 'slack',
value: 'another-channel-1',
},
{
type: 'slack',
value: 'another-channel-2',
},
],
};
const parameters = {
url: 'http://hooks.slack.com',
defaultChannel: 'some-channel',
};
await addon.handleEvent(event, parameters);
const req1 = JSON.parse(addon.fetchRetryCalls[0].options.body);
const req2 = JSON.parse(addon.fetchRetryCalls[1].options.body);
t.is(addon.fetchRetryCalls.length, 2);
t.is(req1.channel, '#another-channel-1');
t.is(req2.channel, '#another-channel-2');
});

View File

@ -0,0 +1,11 @@
# Snapshot report for `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://localhost:4242/#/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://localhost:4242/#/features/strategies/some-toggle"}]}]}'

Binary file not shown.

View File

@ -0,0 +1,53 @@
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
} = require('../event-type');
module.exports = {
name: 'webhook',
displayName: 'Webhook',
description:
'A Webhook is a generic way to post messages from Unleash to third party services.',
documentationUrl: 'https://unleash.github.io/docs/addons/webhook',
parameters: [
{
name: 'url',
displayName: 'Webhook URL',
description:
'(Required) Unleash will perform a HTTP Post to the specified URL (one retry if first attempt fails)',
type: 'url',
required: true,
},
{
name: 'contentType',
displayName: 'Content-Type',
placeholder: 'application/json',
description:
'(Optional) The Content-Type header to use. Defaults to "application/json".',
type: 'text',
required: false,
},
{
name: 'bodyTemplate',
displayName: 'Body template',
placeholder: `{
"event": "{{event.type}}",
"createdBy": "{{event.createdBy}}",
"featureToggle": "{{event.data.name}}",
"timestamp": "{{event.data.createdAt}}"
}`,
description:
"(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://unleash.github.io/docs/api/admin/events)",
type: 'textfield',
required: false,
},
],
events: [
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
],
};

39
lib/addons/webhook.js Normal file
View File

@ -0,0 +1,39 @@
'use strict';
const Mustache = require('mustache');
const Addon = require('./addon');
const definition = require('./webhook-definition');
class Webhook extends Addon {
constructor(args) {
super(definition, args);
}
async handleEvent(event, parameters) {
const { url, bodyTemplate, contentType } = parameters;
const context = {
event,
};
let body;
if (typeof bodyTemplate === 'string' && bodyTemplate.length > 1) {
body = Mustache.render(bodyTemplate, context);
} else {
body = JSON.stringify(event);
}
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': contentType || 'application/json' },
body,
};
const res = await this.fetchRetry(url, requestOpts);
this.logger.info(
`Handled event "${event.type}". Status code: ${res.status}`,
);
}
}
module.exports = Webhook;

View File

@ -0,0 +1,67 @@
const test = require('ava');
const proxyquire = require('proxyquire').noCallThru();
const { FEATURE_CREATED } = require('../event-type');
const WebhookAddon = proxyquire.load('./webhook', {
'./addon': class Addon {
constructor(definition, { getLogger }) {
this.logger = getLogger('addon/test');
this.fetchRetryCalls = [];
}
async fetchRetry(url, options, retries, backoff) {
this.fetchRetryCalls.push({ url, options, retries, backoff });
return Promise.resolve({ status: 200 });
}
},
});
const noLogger = require('../../test/fixtures/no-logger');
test('Should handle event without "bodyTemplate"', t => {
const addon = new WebhookAddon({ getLogger: noLogger });
const event = {
type: FEATURE_CREATED,
createdBy: 'some@user.com',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://test.webhook.com',
};
addon.handleEvent(event, parameters);
t.is(addon.fetchRetryCalls.length, 1);
t.is(addon.fetchRetryCalls[0].url, parameters.url);
t.is(addon.fetchRetryCalls[0].options.body, JSON.stringify(event));
});
test('Should format event with "bodyTemplate"', t => {
const addon = new WebhookAddon({ getLogger: noLogger });
const event = {
type: FEATURE_CREATED,
createdBy: 'some@user.com',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://test.webhook.com/plain',
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
contentType: 'text/plain',
};
addon.handleEvent(event, parameters);
const call = addon.fetchRetryCalls[0];
t.is(addon.fetchRetryCalls.length, 1);
t.is(call.url, parameters.url);
t.is(call.options.headers['Content-Type'], 'text/plain');
t.is(call.options.body, 'feature-created on toggle some-toggle');
});

105
lib/db/addon-store.js Normal file
View File

@ -0,0 +1,105 @@
'use strict';
const metricsHelper = require('../metrics-helper');
const { DB_TIME } = require('../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;

View File

@ -15,6 +15,7 @@ const UserStore = require('./user-store');
const ProjectStore = require('./project-store');
const TagStore = require('./tag-store');
const TagTypeStore = require('./tag-type-store');
const AddonStore = require('./addon-store');
module.exports.createStores = (config, eventBus) => {
const { getLogger } = config;
@ -50,5 +51,6 @@ module.exports.createStores = (config, eventBus) => {
projectStore: new ProjectStore(db, getLogger),
tagStore: new TagStore(db, eventBus, getLogger),
tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
addonStore: new AddonStore(db, eventBus, getLogger),
};
};

View File

@ -87,7 +87,7 @@ function baseTypeFor(event) {
if (event.type === APPLICATION_CREATED) {
return 'application';
}
throw new Error(`unknown event type: ${JSON.stringify(event)}`);
return event.type;
}
function groupByBaseTypeAndName(events) {

View File

@ -4,15 +4,15 @@ const test = require('ava');
const eventDiffer = require('./event-differ');
const { FEATURE_CREATED, FEATURE_UPDATED } = require('./event-type');
test('fails if events include an unknown event type', t => {
test('should not fail if events include an unknown event type', t => {
const events = [
{ type: FEATURE_CREATED, data: {} },
{ type: 'unknown-type', data: {} },
];
t.throws(() => {
eventDiffer.addDiffs(events);
});
eventDiffer.addDiffs(events);
t.true(true, 'No exceptions here =)');
});
test('diffs a feature-update event', t => {

View File

@ -28,4 +28,7 @@ module.exports = {
TAG_TYPE_CREATED: 'tag-type-created',
TAG_TYPE_DELETED: 'tag-type-deleted',
TAG_TYPE_UPDATED: 'tag-type-updated',
ADDON_CONFIG_CREATED: 'addon-config-created',
ADDON_CONFIG_UPDATED: 'addon-config-updated',
ADDON_CONFIG_DELETED: 'addon-config-deleted',
};

View File

@ -15,6 +15,9 @@ const DELETE_CONTEXT_FIELD = 'DELETE_CONTEXT_FIELD';
const CREATE_PROJECT = 'CREATE_PROJECT';
const UPDATE_PROJECT = 'UPDATE_PROJECT';
const DELETE_PROJECT = 'DELETE_PROJECT';
const CREATE_ADDON = 'CREATE_ADDON';
const UPDATE_ADDON = 'UPDATE_ADDON';
const DELETE_ADDON = 'DELETE_ADDON';
module.exports = {
ADMIN,
@ -32,4 +35,7 @@ module.exports = {
CREATE_PROJECT,
UPDATE_PROJECT,
DELETE_PROJECT,
CREATE_ADDON,
DELETE_ADDON,
UPDATE_ADDON,
};

View File

@ -0,0 +1,86 @@
'use strict';
const Controller = require('../controller');
const extractUser = require('../../extract-user');
const { handleErrors } = require('./util');
const {
CREATE_ADDON,
UPDATE_ADDON,
DELETE_ADDON,
} = require('../../permissions');
class AddonController extends Controller {
constructor(config, { addonService }) {
super(config);
this.logger = config.getLogger('/admin-api/addon.js');
this.addonService = addonService;
this.get('/', this.getAddons);
this.post('/', this.createAddon, CREATE_ADDON);
this.get('/:id', this.getAddon);
this.put('/:id', this.updateAddon, UPDATE_ADDON);
this.delete('/:id', this.deleteAddon, DELETE_ADDON);
}
async getAddons(req, res) {
try {
const addons = await this.addonService.getAddons();
const providers = await this.addonService.getProviderDefinition();
res.json({ addons, providers });
} catch (error) {
handleErrors(res, this.logger, error);
}
}
async getAddon(req, res) {
const { id } = req.params;
try {
const addon = await this.addonService.getAddon(id);
res.json(addon);
} catch (error) {
handleErrors(res, this.logger, error);
}
}
async updateAddon(req, res) {
const { id } = req.params;
const createdBy = extractUser(req);
const data = req.body;
try {
const addon = await this.addonService.updateAddon(
id,
data,
createdBy,
);
res.status(200).json(addon);
} catch (error) {
handleErrors(res, this.logger, error);
}
}
async createAddon(req, res) {
const createdBy = extractUser(req);
const data = req.body;
try {
const addon = await this.addonService.createAddon(data, createdBy);
res.status(201).json(addon);
} catch (error) {
handleErrors(res, this.logger, error);
}
}
async deleteAddon(req, res) {
const { id } = req.params;
const username = extractUser(req);
try {
await this.addonService.removeAddon(id, username);
res.status(200).end();
} catch (error) {
handleErrors(res, this.logger, error);
}
}
}
module.exports = AddonController;

View File

@ -13,6 +13,7 @@ const ContextController = require('./context');
const StateController = require('./state');
const TagController = require('./tag');
const TagTypeController = require('./tag-type');
const AddonController = require('./addon');
const apiDef = require('./api-def.json');
class AdminApi extends Controller {
@ -56,6 +57,7 @@ class AdminApi extends Controller {
'/tag-types',
new TagTypeController(config, services).router,
);
this.app.use('/addons', new AddonController(config, services).router);
}
index(req, res) {

View File

@ -0,0 +1,27 @@
const joi = require('joi');
const { nameType } = require('../routes/admin-api/util');
const addonSchema = joi
.object()
.keys({
provider: nameType,
enabled: joi.bool().default(true),
description: joi
.string()
.allow(null)
.allow('')
.optional(),
parameters: joi
.object()
.pattern(joi.string(), [joi.string(), joi.number(), joi.boolean()])
.optional(),
events: joi
.array()
.optional()
.items(joi.string()),
})
.options({ allowUnknown: false, stripUnknown: true });
module.exports = {
addonSchema,
};

View File

@ -0,0 +1,154 @@
'use strict';
const memoize = require('memoizee');
const addonProvidersClasses = require('../addons');
const events = require('../event-type');
const { addonSchema } = require('./addon-schema');
const NameExistsError = require('../error/name-exists-error');
const SUPPORTED_EVENTS = Object.keys(events).map(k => events[k]);
const ADDONS_CACHE_TIME = 60 * 1000; // 60s
class AddonService {
constructor(
{ addonStore, eventStore, featureToggleStore },
{ getLogger },
tagTypeService,
) {
this.eventStore = eventStore;
this.addonStore = addonStore;
this.featureToggleStore = featureToggleStore;
this.getLogger = getLogger;
this.logger = getLogger('services/addon-service.js');
this.tagTypeService = tagTypeService;
this.addonProviders = addonProvidersClasses.reduce((map, Provider) => {
try {
const provider = new Provider({ getLogger });
// eslint-disable-next-line no-param-reassign
map[provider.name] = provider;
} finally {
// Do nothing
}
return map;
}, {});
if (addonStore) {
this.registerEventHandler();
}
// Memoized function
this.fetchAddonConfigs = memoize(
() => addonStore.getAll({ enabled: true }),
{ promise: true, maxAge: ADDONS_CACHE_TIME },
);
}
registerEventHandler() {
SUPPORTED_EVENTS.forEach(eventName =>
this.eventStore.on(eventName, this.handleEvent(eventName)),
);
}
handleEvent(eventName) {
const { addonProviders } = this;
return event => {
this.fetchAddonConfigs().then(addonInstances => {
addonInstances
.filter(addon => addon.events.includes(eventName))
.filter(addon => addonProviders[addon.provider])
.forEach(addon =>
addonProviders[addon.provider].handleEvent(
event,
addon.parameters,
),
);
});
};
}
async getAddons() {
return this.addonStore.getAll();
}
async getAddon(id) {
return this.addonStore.get(id);
}
getProviderDefinition() {
const { addonProviders } = this;
return Object.values(addonProviders).map(p => p.definition);
}
async addTagTypes(providerName) {
const provider = this.addonProviders[providerName];
if (provider) {
const tagTypes = provider.definition.tagTypes || [];
const createTags = tagTypes.map(async tagType => {
try {
await this.tagTypeService.validateUnique(tagType);
await this.tagTypeService.createTagType(tagType);
} catch (err) {
this.logger.error(err);
if (!(err instanceof NameExistsError)) {
this.logger.error(err);
}
}
});
return Promise.all(createTags);
}
return Promise.resolve();
}
async createAddon(data, userName) {
const addonConfig = await addonSchema.validateAsync(data);
await this.validateKnownProvider(addonConfig);
const createdAddon = await this.addonStore.insert(addonConfig);
await this.addTagTypes(createdAddon.provider);
this.logger.info(
`User ${userName} created addon ${addonConfig.provider}`,
);
await this.eventStore.store({
type: events.ADDON_CONFIG_CREATED,
createdBy: userName,
data: { provider: addonConfig.provider },
});
return createdAddon;
}
async updateAddon(id, data, userName) {
const addonConfig = await addonSchema.validateAsync(data);
await this.addonStore.update(id, addonConfig);
await this.eventStore.store({
type: events.ADDON_CONFIG_UPDATED,
createdBy: userName,
data: { id, provider: addonConfig.provider },
});
this.logger.info(`User ${userName} updated addon ${id}`);
}
async removeAddon(id, userName) {
await this.addonStore.delete(id);
await this.eventStore.store({
type: events.ADDON_CONFIG_DELETED,
createdBy: userName,
data: { id },
});
this.logger.info(`User ${userName} removed addon ${id}`);
}
async validateKnownProvider(config) {
const p = this.addonProviders[config.provider];
if (!p) {
throw new TypeError(`Unknown addon provider ${config.provider}`);
} else {
return true;
}
}
}
module.exports = AddonService;

View File

@ -0,0 +1,261 @@
'use strict';
const test = require('ava');
const proxyquire = require('proxyquire').noCallThru();
const Addon = require('../addons/addon');
const store = require('../../test/fixtures/store');
const getLogger = require('../../test/fixtures/no-logger');
const TagTypeService = require('./tag-type-service');
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
ADDON_CONFIG_CREATED,
ADDON_CONFIG_UPDATED,
ADDON_CONFIG_DELETED,
} = require('../event-type');
const definition = {
name: 'simple',
displayName: 'Simple ADdon',
description: 'Some description',
parameters: [
{
name: 'url',
displayName: 'Some URL',
type: 'url',
required: true,
},
{
name: 'var',
displayName: 'Some var',
description: 'Some variable to inject',
type: 'text',
required: false,
},
],
events: [
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
],
tagTypes: [
{
name: 'me',
description: 'Some tag',
icon: 'm',
},
],
};
class SimpleAddon extends Addon {
constructor() {
super(definition, { getLogger });
this.events = [];
}
getEvents() {
return this.events;
}
async handleEvent(event, parameters) {
this.events.push({
event,
parameters,
});
}
}
const AddonService = proxyquire.load('./addon-service', {
'../addons': new Array(SimpleAddon),
});
function getSetup() {
const stores = store.createStores();
const tagTypeService = new TagTypeService(stores, { getLogger });
return {
addonService: new AddonService(stores, { getLogger }, tagTypeService),
stores,
tagTypeService,
};
}
test('should load addon configurations', async t => {
const { addonService } = getSetup();
const configs = await addonService.getAddons();
t.is(configs.length, 0);
});
test('should load provider definitions', async t => {
const { addonService } = getSetup();
const providerDefinitions = await addonService.getProviderDefinition();
const simple = providerDefinitions.find(p => p.name === 'simple');
t.is(providerDefinitions.length, 1);
t.is(simple.name, 'simple');
});
test('should not allow addon-config for unknown provider', async t => {
const { addonService } = getSetup();
const error = await t.throwsAsync(
async () => {
await addonService.createAddon({ provider: 'unknown' });
},
{ instanceOf: TypeError },
);
t.is(error.message, 'Unknown addon provider unknown');
});
test('should trigger simple-addon eventHandler', async t => {
const { addonService, stores } = getSetup();
const config = {
provider: 'simple',
enabled: true,
parameters: {
url: 'http://localhost/wh',
var: 'some-value',
},
events: [FEATURE_CREATED],
};
await addonService.createAddon(config, 'me@mail.com');
// Feature toggle was created
await stores.eventStore.store({
type: FEATURE_CREATED,
createdBy: 'some@user.com',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
});
const simpleProvider = addonService.addonProviders.simple;
const events = simpleProvider.getEvents();
t.is(events.length, 1);
t.is(events[0].event.type, FEATURE_CREATED);
t.is(events[0].event.data.name, 'some-toggle');
});
test('should create simple-addon config', async t => {
const { addonService } = getSetup();
const config = {
provider: 'simple',
enabled: true,
parameters: {
url: 'http://localhost/wh',
var: 'some-value',
},
events: [FEATURE_CREATED],
};
await addonService.createAddon(config, 'me@mail.com');
const addons = await addonService.getAddons();
t.is(addons.length, 1);
t.is(addons[0].provider, 'simple');
});
test('should create tag type for simple-addon', async t => {
const { addonService, tagTypeService } = getSetup();
const config = {
provider: 'simple',
enabled: true,
parameters: {
url: 'http://localhost/wh',
var: 'some-value',
},
events: [FEATURE_CREATED],
};
await addonService.createAddon(config, 'me@mail.com');
const tagType = await tagTypeService.getTagType('me');
t.is(tagType.name, 'me');
});
test('should store ADDON_CONFIG_CREATE event', async t => {
const { addonService, stores } = getSetup();
const config = {
provider: 'simple',
enabled: true,
parameters: {
url: 'http://localhost/wh',
var: 'some-value',
},
events: [FEATURE_CREATED],
};
await addonService.createAddon(config, 'me@mail.com');
const events = await stores.eventStore.getEvents();
t.is(events.length, 2); // Also tag-types where created
t.is(events[1].type, ADDON_CONFIG_CREATED);
t.is(events[1].data.provider, 'simple');
});
test('should store ADDON_CONFIG_UPDATE event', async t => {
const { addonService, stores } = getSetup();
const config = {
provider: 'simple',
enabled: true,
parameters: {
url: 'http://localhost/wh',
var: 'some-value',
},
events: [FEATURE_CREATED],
};
const addonConfig = await addonService.createAddon(config, 'me@mail.com');
const updated = { ...addonConfig, description: 'test' };
await addonService.updateAddon(addonConfig.id, updated, 'me@mail.com');
const events = await stores.eventStore.getEvents();
t.is(events.length, 3);
t.is(events[2].type, ADDON_CONFIG_UPDATED);
t.is(events[2].data.provider, 'simple');
});
test('should store ADDON_CONFIG_REMOVE event', async t => {
const { addonService, stores } = getSetup();
const config = {
provider: 'simple',
enabled: true,
parameters: {
url: 'http://localhost/wh',
var: 'some-value',
},
events: [FEATURE_CREATED],
};
const addonConfig = await addonService.createAddon(config, 'me@mail.com');
await addonService.removeAddon(addonConfig.id, 'me@mail.com');
const events = await stores.eventStore.getEvents();
t.is(events.length, 3);
t.is(events[2].type, ADDON_CONFIG_DELETED);
t.is(events[2].data.id, addonConfig.id);
});

View File

@ -5,13 +5,26 @@ const ClientMetricsService = require('./client-metrics');
const TagTypeService = require('./tag-type-service');
const TagService = require('./tag-service');
const StrategyService = require('./strategy-service');
const AddonService = require('./addon-service');
module.exports.createServices = (stores, config) => ({
featureToggleService: new FeatureToggleService(stores, config),
projectService: new ProjectService(stores, config),
stateService: new StateService(stores, config),
strategyService: new StrategyService(stores, config),
tagTypeService: new TagTypeService(stores, config),
tagService: new TagService(stores, config),
clientMetricsService: new ClientMetricsService(stores, config),
});
module.exports.createServices = (stores, config) => {
const featureToggleService = new FeatureToggleService(stores, config);
const projectService = new ProjectService(stores, config);
const stateService = new StateService(stores, config);
const strategyService = new StrategyService(stores, config);
const tagTypeService = new TagTypeService(stores, config);
const tagService = new TagService(stores, config);
const clientMetricsService = new ClientMetricsService(stores, config);
const addonService = new AddonService(stores, config, tagTypeService);
return {
addonService,
featureToggleService,
projectService,
stateService,
strategyService,
tagTypeService,
tagService,
clientMetricsService,
};
};

View File

@ -0,0 +1,20 @@
exports.up = function(db, cb) {
db.runSql(
`CREATE TABLE IF NOT EXISTS addons
(
id SERIAL PRIMARY KEY,
provider text not null,
description text,
enabled boolean default true,
parameters json,
events json,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
`,
cb,
);
};
exports.down = function(db, cb) {
db.runSql(`DROP TABLE addons;`, cb);
};

View File

@ -76,9 +76,12 @@
"js-yaml": "^3.14.0",
"knex": "0.21.15",
"log4js": "^6.0.0",
"memoizee": "^0.4.15",
"mime": "^2.4.2",
"moment": "^2.24.0",
"multer": "^1.4.1",
"mustache": "^4.1.0",
"node-fetch": "^2.6.1",
"parse-database-url": "^0.3.0",
"pg": "^8.0.3",
"pkginfo": "^0.4.1",
@ -99,6 +102,7 @@
"eslint-config-prettier": "^6.10.1",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-prettier": "^3.1.3",
"fetch-mock": "^9.11.0",
"husky": "^4.2.3",
"lint-staged": "^10.0.7",
"lolex": "^6.0.0",

View File

@ -0,0 +1,209 @@
'use strict';
const test = require('ava');
const dbInit = require('../../helpers/database-init');
const { setupApp } = require('../../helpers/test-helper');
const getLogger = require('../../../fixtures/no-logger');
let stores;
test.before(async () => {
const db = await dbInit('addon_api_serial', getLogger);
stores = db.stores;
});
test.after(async () => {
await stores.db.destroy();
});
test.serial('gets all addons', async t => {
t.plan(3);
const request = await setupApp(stores);
return request
.get('/api/admin/addons')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.addons.length, 0, 'expected 0 configured addons');
t.is(res.body.providers.length, 3, 'expected 3 addon providers');
t.is(res.body.providers[0].name, 'webhook');
});
});
test.serial('should not be able to create invalid addon', async t => {
t.plan(0);
const request = await setupApp(stores);
return request
.post('/api/admin/addons')
.send({ invalid: 'field' })
.expect(400);
});
test.serial('should create addon configuration', async t => {
t.plan(0);
const request = await setupApp(stores);
const config = {
provider: 'webhook',
enabled: true,
parameters: {
url: 'http://localhost:4242/webhook',
bodyTemplate: "{'name': '{{event.data.name}}' }",
},
events: ['feature-updated', 'feature-created'],
};
return request
.post('/api/admin/addons')
.send(config)
.expect(201);
});
test.serial('should delete addon configuration', async t => {
t.plan(0);
const request = await setupApp(stores);
const config = {
provider: 'webhook',
enabled: true,
parameters: {
url: 'http://localhost:4242/webhook',
bodyTemplate: "{'name': '{{event.data.name}}' }",
},
events: ['feature-updated', 'feature-created'],
};
const res = await request
.post('/api/admin/addons')
.send(config)
.expect(201);
const { id } = res.body;
await request.delete(`/api/admin/addons/${id}`).expect(200);
});
test.serial('should update addon configuration', async t => {
t.plan(1);
const request = await setupApp(stores);
const config = {
provider: 'webhook',
enabled: true,
parameters: {
url: 'http://localhost:4242/webhook',
bodyTemplate: "{'name': '{{event.data.name}}' }",
},
events: ['feature-updated', 'feature-created'],
};
const res = await request
.post('/api/admin/addons')
.send(config)
.expect(201);
const { id } = res.body;
const updatedConfig = {
parameters: {
url: 'http://example.com',
bodyTemplate: "{'name': '{{event.data.name}}' }",
},
...config,
};
await request
.put(`/api/admin/addons/${id}`)
.send(updatedConfig)
.expect(200);
return request
.get(`/api/admin/addons/${id}`)
.send(config)
.expect(200)
.expect(r => {
t.is(r.body.parameters.url, updatedConfig.parameters.url);
});
});
test.serial('should not update with invalid addon configuration', async t => {
t.plan(0);
const request = await setupApp(stores);
const config = {
enabled: true,
parameters: {
url: 'http://localhost:4242/webhook',
bodyTemplate: "{'name': '{{event.data.name}}' }",
},
events: ['feature-updated', 'feature-created'],
};
await request
.put(`/api/admin/addons/1`)
.send(config)
.expect(400);
});
test.serial('should not update unknwn addon configuration', async t => {
t.plan(0);
const request = await setupApp(stores);
const config = {
provider: 'webhook',
enabled: true,
parameters: {
url: 'http://localhost:4242/webhook',
bodyTemplate: "{'name': '{{event.data.name}}' }",
},
events: ['feature-updated', 'feature-created'],
};
await request
.put(`/api/admin/addons/123123`)
.send(config)
.expect(404);
});
test.serial('should get addon configuration', async t => {
t.plan(1);
const request = await setupApp(stores);
const config = {
provider: 'webhook',
enabled: true,
parameters: {
url: 'http://localhost:4242/webhook',
bodyTemplate: "{'name': '{{event.data.name}}' }",
},
events: ['feature-updated', 'feature-created'],
};
const res = await request
.post('/api/admin/addons')
.send(config)
.expect(201);
const { id } = res.body;
await request
.get(`/api/admin/addons/${id}`)
.expect(200)
.expect(r => {
t.is(r.body.provider, config.provider);
});
});
test.serial('should not get unkown addon configuration', async t => {
t.plan(0);
const request = await setupApp(stores);
await request.get(`/api/admin/addons/445`).expect(404);
});
test.serial('should not delete unknown addon configuration', async t => {
t.plan(0);
const request = await setupApp(stores);
return request.delete('/api/admin/addons/21231').expect(404);
});

View File

@ -27,6 +27,7 @@ async function resetDatabase(stores) {
stores.db('projects').del(),
stores.db('tags').del(),
stores.db('tag_types').del(),
stores.db('addons').del(),
]);
}
@ -97,6 +98,7 @@ module.exports = async function init(databaseSchema = 'test', getLogger) {
await db.destroy();
const stores = await createStores(options, eventBus);
stores.clientMetricsStore.setMaxListeners(0);
stores.eventStore.setMaxListeners(0);
await resetDatabase(stores);
await setupDatabase(stores);

25
test/fixtures/fake-addon-store.js vendored Normal file
View File

@ -0,0 +1,25 @@
'use strict';
module.exports = () => {
const _addons = [];
return {
insert: async addon => {
const a = { id: _addons.length, ...addon };
_addons.push(a);
return a;
},
update: async (id, value) => {
_addons[id] = value;
Promise.resolve(value);
},
delete: async id => {
_addons.splice(id, 1);
Promise.resolve();
},
get: async id => {
return _addons.find(id);
},
getAll: () => Promise.resolve(_addons),
};
};

View File

@ -1,13 +1,25 @@
'use strict';
module.exports = () => {
const events = [];
const { EventEmitter } = require('events');
return {
store: event => {
events.push(event);
return Promise.resolve();
},
getEvents: () => Promise.resolve(events),
};
class EventStore extends EventEmitter {
constructor() {
super();
this.setMaxListeners(0);
this.events = [];
}
store(event) {
this.events.push(event);
this.emit(event.type, event);
return Promise.resolve();
}
getEvents() {
return Promise.resolve(this.events);
}
}
module.exports = () => {
return new EventStore();
};

17
test/fixtures/fake-tag-type-store.js vendored Normal file
View File

@ -0,0 +1,17 @@
const NotFoundError = require('../../lib/error/notfound-error');
module.exports = () => {
const _tagTypes = {};
return {
getTagType: async name => {
const tag = _tagTypes[name];
if (tag) {
return Promise.resolve(tag);
}
return Promise.reject(new NotFoundError('Could not find tag type'));
},
createTagType: async tag => {
_tagTypes[tag.name] = tag;
},
};
};

View File

@ -5,10 +5,12 @@ const clientInstanceStore = require('./fake-client-instance-store');
const clientApplicationsStore = require('./fake-client-applications-store');
const featureToggleStore = require('./fake-feature-toggle-store');
const tagStore = require('./fake-tag-store');
const tagTypeStore = require('./fake-tag-type-store');
const eventStore = require('./fake-event-store');
const strategyStore = require('./fake-strategies-store');
const contextFieldStore = require('./fake-context-store');
const settingStore = require('./fake-setting-store');
const addonStore = require('./fake-addon-store');
module.exports = {
createStores: () => {
@ -25,10 +27,12 @@ module.exports = {
clientInstanceStore: clientInstanceStore(),
featureToggleStore: featureToggleStore(),
tagStore: tagStore(),
tagTypeStore: tagTypeStore(),
eventStore: eventStore(),
strategyStore: strategyStore(),
contextFieldStore: contextFieldStore(),
settingStore: settingStore(),
addonStore: addonStore(),
};
},
};

View File

@ -8,6 +8,15 @@
"activation_strategy": {
"title": "Activation Strategies"
},
"addons": {
"title": "Unleash Addons"
},
"addons/webhook": {
"title": "Unleash Webhook addon"
},
"api/admin/addons": {
"title": "/api/admin/addons"
},
"api/admin/events": {
"title": "/api/admin/events"
},

View File

@ -9,6 +9,7 @@
"securing_unleash",
"unleash_context",
"activation_strategy",
"addons",
"client_specification",
"migration_guide"
],
@ -36,7 +37,8 @@
"api/admin/metrics",
"api/admin/events",
"api/admin/state",
"api/admin/feature-types"
"api/admin/feature-types",
"api/admin/addons"
],
"Internal": ["api/internal"],
"Specification": ["api/open_api"]

369
yarn.lock
View File

@ -9,6 +9,34 @@
dependencies:
"@babel/highlight" "^7.10.4"
"@babel/code-frame@^7.12.11":
version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==
dependencies:
"@babel/highlight" "^7.10.4"
"@babel/core@^7.0.0":
version "7.12.10"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.10.tgz#b79a2e1b9f70ed3d84bbfb6d8c4ef825f606bccd"
integrity sha512-eTAlQKq65zHfkHZV0sIVODCPGVgoo1HdBlbSLi9CqOzuZanMv2ihzY+4paiKr1mH+XmYESMAmJ/dpZ68eN6d8w==
dependencies:
"@babel/code-frame" "^7.10.4"
"@babel/generator" "^7.12.10"
"@babel/helper-module-transforms" "^7.12.1"
"@babel/helpers" "^7.12.5"
"@babel/parser" "^7.12.10"
"@babel/template" "^7.12.7"
"@babel/traverse" "^7.12.10"
"@babel/types" "^7.12.10"
convert-source-map "^1.7.0"
debug "^4.1.0"
gensync "^1.0.0-beta.1"
json5 "^2.1.2"
lodash "^4.17.19"
semver "^5.4.1"
source-map "^0.5.0"
"@babel/core@^7.7.5":
version "7.11.1"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.11.1.tgz"
@ -40,6 +68,15 @@
jsesc "^2.5.1"
source-map "^0.5.0"
"@babel/generator@^7.12.10", "@babel/generator@^7.12.11":
version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.11.tgz#98a7df7b8c358c9a37ab07a24056853016aba3af"
integrity sha512-Ggg6WPOJtSi8yYQvLVjG8F/TlpWDlKx0OpS4Kt+xMQPs5OaGYWy+v1A+1TvxI6sAMGZpKWWoAQ1DaeQbImlItA==
dependencies:
"@babel/types" "^7.12.11"
jsesc "^2.5.1"
source-map "^0.5.0"
"@babel/helper-function-name@^7.10.4":
version "7.10.4"
resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz"
@ -49,6 +86,15 @@
"@babel/template" "^7.10.4"
"@babel/types" "^7.10.4"
"@babel/helper-function-name@^7.12.11":
version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.11.tgz#1fd7738aee5dcf53c3ecff24f1da9c511ec47b42"
integrity sha512-AtQKjtYNolKNi6nNNVLQ27CP6D9oFR6bq/HPYSizlzbp7uC1M59XJe8L+0uXjbIaZaUJF99ruHqVGiKXU/7ybA==
dependencies:
"@babel/helper-get-function-arity" "^7.12.10"
"@babel/template" "^7.12.7"
"@babel/types" "^7.12.11"
"@babel/helper-get-function-arity@^7.10.4":
version "7.10.4"
resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz"
@ -56,6 +102,13 @@
dependencies:
"@babel/types" "^7.10.4"
"@babel/helper-get-function-arity@^7.12.10":
version "7.12.10"
resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf"
integrity sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag==
dependencies:
"@babel/types" "^7.12.10"
"@babel/helper-member-expression-to-functions@^7.10.4":
version "7.11.0"
resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz"
@ -63,6 +116,13 @@
dependencies:
"@babel/types" "^7.11.0"
"@babel/helper-member-expression-to-functions@^7.12.7":
version "7.12.7"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855"
integrity sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw==
dependencies:
"@babel/types" "^7.12.7"
"@babel/helper-module-imports@^7.10.4":
version "7.10.4"
resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz"
@ -70,6 +130,13 @@
dependencies:
"@babel/types" "^7.10.4"
"@babel/helper-module-imports@^7.12.1":
version "7.12.5"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb"
integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA==
dependencies:
"@babel/types" "^7.12.5"
"@babel/helper-module-transforms@^7.11.0":
version "7.11.0"
resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz"
@ -83,6 +150,21 @@
"@babel/types" "^7.11.0"
lodash "^4.17.19"
"@babel/helper-module-transforms@^7.12.1":
version "7.12.1"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c"
integrity sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w==
dependencies:
"@babel/helper-module-imports" "^7.12.1"
"@babel/helper-replace-supers" "^7.12.1"
"@babel/helper-simple-access" "^7.12.1"
"@babel/helper-split-export-declaration" "^7.11.0"
"@babel/helper-validator-identifier" "^7.10.4"
"@babel/template" "^7.10.4"
"@babel/traverse" "^7.12.1"
"@babel/types" "^7.12.1"
lodash "^4.17.19"
"@babel/helper-optimise-call-expression@^7.10.4":
version "7.10.4"
resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz"
@ -90,6 +172,13 @@
dependencies:
"@babel/types" "^7.10.4"
"@babel/helper-optimise-call-expression@^7.12.10":
version "7.12.10"
resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz#94ca4e306ee11a7dd6e9f42823e2ac6b49881e2d"
integrity sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ==
dependencies:
"@babel/types" "^7.12.10"
"@babel/helper-replace-supers@^7.10.4":
version "7.10.4"
resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz"
@ -100,6 +189,16 @@
"@babel/traverse" "^7.10.4"
"@babel/types" "^7.10.4"
"@babel/helper-replace-supers@^7.12.1":
version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.12.11.tgz#ea511658fc66c7908f923106dd88e08d1997d60d"
integrity sha512-q+w1cqmhL7R0FNzth/PLLp2N+scXEK/L2AHbXUyydxp828F4FEa5WcVoqui9vFRiHDQErj9Zof8azP32uGVTRA==
dependencies:
"@babel/helper-member-expression-to-functions" "^7.12.7"
"@babel/helper-optimise-call-expression" "^7.12.10"
"@babel/traverse" "^7.12.10"
"@babel/types" "^7.12.11"
"@babel/helper-simple-access@^7.10.4":
version "7.10.4"
resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz"
@ -108,6 +207,13 @@
"@babel/template" "^7.10.4"
"@babel/types" "^7.10.4"
"@babel/helper-simple-access@^7.12.1":
version "7.12.1"
resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136"
integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA==
dependencies:
"@babel/types" "^7.12.1"
"@babel/helper-split-export-declaration@^7.11.0":
version "7.11.0"
resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz"
@ -115,11 +221,23 @@
dependencies:
"@babel/types" "^7.11.0"
"@babel/helper-split-export-declaration@^7.12.11":
version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.11.tgz#1b4cc424458643c47d37022223da33d76ea4603a"
integrity sha512-LsIVN8j48gHgwzfocYUSkO/hjYAOJqlpJEc7tGXcIm4cubjVUf8LGW6eWRyxEu7gA25q02p0rQUWoCI33HNS5g==
dependencies:
"@babel/types" "^7.12.11"
"@babel/helper-validator-identifier@^7.10.4":
version "7.10.4"
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz"
integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==
"@babel/helper-validator-identifier@^7.12.11":
version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed"
integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==
"@babel/helpers@^7.10.4":
version "7.10.4"
resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz"
@ -129,6 +247,15 @@
"@babel/traverse" "^7.10.4"
"@babel/types" "^7.10.4"
"@babel/helpers@^7.12.5":
version "7.12.5"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e"
integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA==
dependencies:
"@babel/template" "^7.10.4"
"@babel/traverse" "^7.12.5"
"@babel/types" "^7.12.5"
"@babel/highlight@^7.10.4":
version "7.10.4"
resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz"
@ -143,6 +270,18 @@
resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.2.tgz"
integrity sha512-Vuj/+7vLo6l1Vi7uuO+1ngCDNeVmNbTngcJFKCR/oEtz8tKz0CJxZEGmPt9KcIloZhOZ3Zit6xbpXT2MDlS9Vw==
"@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7":
version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79"
integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg==
"@babel/runtime@^7.0.0":
version "7.12.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.10.4":
version "7.10.4"
resolved "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz"
@ -152,6 +291,15 @@
"@babel/parser" "^7.10.4"
"@babel/types" "^7.10.4"
"@babel/template@^7.12.7":
version "7.12.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc"
integrity sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow==
dependencies:
"@babel/code-frame" "^7.10.4"
"@babel/parser" "^7.12.7"
"@babel/types" "^7.12.7"
"@babel/traverse@^7.10.4", "@babel/traverse@^7.11.0":
version "7.11.0"
resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.0.tgz"
@ -167,6 +315,21 @@
globals "^11.1.0"
lodash "^4.17.19"
"@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.5":
version "7.12.12"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376"
integrity sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w==
dependencies:
"@babel/code-frame" "^7.12.11"
"@babel/generator" "^7.12.11"
"@babel/helper-function-name" "^7.12.11"
"@babel/helper-split-export-declaration" "^7.12.11"
"@babel/parser" "^7.12.11"
"@babel/types" "^7.12.12"
debug "^4.1.0"
globals "^11.1.0"
lodash "^4.17.19"
"@babel/types@^7.10.4", "@babel/types@^7.11.0":
version "7.11.0"
resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.0.tgz"
@ -176,6 +339,15 @@
lodash "^4.17.19"
to-fast-properties "^2.0.0"
"@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.7":
version "7.12.12"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299"
integrity sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ==
dependencies:
"@babel/helper-validator-identifier" "^7.12.11"
lodash "^4.17.19"
to-fast-properties "^2.0.0"
"@concordance/react@^2.0.0":
version "2.0.0"
resolved "https://registry.npmjs.org/@concordance/react/-/react-2.0.0.tgz"
@ -1251,6 +1423,11 @@ copy-descriptor@^0.1.0:
resolved "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz"
integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
core-js@^3.0.0:
version "3.8.3"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.8.3.tgz#c21906e1f14f3689f93abcc6e26883550dd92dd0"
integrity sha512-KPYXeVZYemC2TkNEkX/01I+7yd+nX3KddKwZ1Ww7SKWdI2wQprSgLmrTddT8nw92AjEklTsPBoSdQBhbI1bQ6Q==
core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
@ -1315,6 +1492,14 @@ cycle@1.0.x:
resolved "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz"
integrity sha1-IegLK+hYD5i0aPN5QwZisEbDStI=
d@1, d@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a"
integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==
dependencies:
es5-ext "^0.10.50"
type "^1.0.1"
dashdash@^1.12.0:
version "1.14.1"
resolved "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz"
@ -1698,11 +1883,29 @@ es-to-primitive@^1.2.1:
is-date-object "^1.0.1"
is-symbol "^1.0.2"
es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46:
version "0.10.53"
resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1"
integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==
dependencies:
es6-iterator "~2.0.3"
es6-symbol "~3.1.3"
next-tick "~1.0.0"
es6-error@^4.0.1:
version "4.1.1"
resolved "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz"
integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==
es6-iterator@^2.0.3, es6-iterator@~2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c=
dependencies:
d "1"
es5-ext "^0.10.35"
es6-symbol "^3.1.1"
es6-promise@^4.0.3:
version "4.2.8"
resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz"
@ -1715,6 +1918,24 @@ es6-promisify@^5.0.0:
dependencies:
es6-promise "^4.0.3"
es6-symbol@^3.1.1, es6-symbol@~3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18"
integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==
dependencies:
d "^1.0.1"
ext "^1.1.2"
es6-weak-map@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53"
integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==
dependencies:
d "1"
es5-ext "^0.10.46"
es6-iterator "^2.0.3"
es6-symbol "^3.1.1"
escalade@^3.0.2:
version "3.1.0"
resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.0.tgz"
@ -1919,6 +2140,14 @@ etag@~1.8.1:
resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
event-emitter@^0.3.5:
version "0.3.5"
resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39"
integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=
dependencies:
d "1"
es5-ext "~0.10.14"
event-target-shim@^5.0.0:
version "5.0.1"
resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz"
@ -1995,6 +2224,13 @@ express@^4.17.1:
utils-merge "1.0.1"
vary "~1.1.2"
ext@^1.1.2:
version "1.4.0"
resolved "https://registry.yarnpkg.com/ext/-/ext-1.4.0.tgz#89ae7a07158f79d35517882904324077e4379244"
integrity sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==
dependencies:
type "^2.0.0"
extend-shallow@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz"
@ -2097,6 +2333,22 @@ fastq@^1.6.0:
dependencies:
reusify "^1.0.4"
fetch-mock@^9.11.0:
version "9.11.0"
resolved "https://registry.yarnpkg.com/fetch-mock/-/fetch-mock-9.11.0.tgz#371c6fb7d45584d2ae4a18ee6824e7ad4b637a3f"
integrity sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==
dependencies:
"@babel/core" "^7.0.0"
"@babel/runtime" "^7.0.0"
core-js "^3.0.0"
debug "^4.1.1"
glob-to-regexp "^0.4.0"
is-subset "^0.1.1"
lodash.isequal "^4.5.0"
path-to-regexp "^2.2.1"
querystring "^0.2.0"
whatwg-url "^6.5.0"
figures@^3.0.0, figures@^3.2.0:
version "3.2.0"
resolved "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz"
@ -2415,6 +2667,11 @@ glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0:
dependencies:
is-glob "^4.0.1"
glob-to-regexp@^0.4.0:
version "0.4.1"
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
version "7.1.6"
resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz"
@ -3045,6 +3302,11 @@ is-plain-object@^4.1.1:
resolved "https://registry.npmjs.org/is-plain-object/-/is-plain-object-4.1.1.tgz"
integrity sha512-5Aw8LLVsDlZsETVMhoMXzqsXwQqr/0vlnBYzIXJbYo2F4yYlhLHs+Ez7Bod7IIQKWkJbJfxrWD7pA1Dw1TKrwA==
is-promise@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1"
integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==
is-promise@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz"
@ -3079,6 +3341,11 @@ is-string@^1.0.5:
resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz"
integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
is-subset@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6"
integrity sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=
is-symbol@^1.0.2:
version "1.0.3"
resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz"
@ -3509,6 +3776,16 @@ lodash.flattendeep@^4.4.0:
resolved "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz"
integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20:
version "4.17.20"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz"
@ -3578,6 +3855,13 @@ lru-cache@^5.0.0:
dependencies:
yallist "^3.0.2"
lru-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3"
integrity sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM=
dependencies:
es5-ext "~0.10.2"
make-dir@^3.0.0, make-dir@^3.0.2:
version "3.1.0"
resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz"
@ -3638,6 +3922,20 @@ mem@^6.1.0:
map-age-cleaner "^0.1.3"
mimic-fn "^3.0.0"
memoizee@^0.4.15:
version "0.4.15"
resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.15.tgz#e6f3d2da863f318d02225391829a6c5956555b72"
integrity sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==
dependencies:
d "^1.0.1"
es5-ext "^0.10.53"
es6-weak-map "^2.0.3"
event-emitter "^0.3.5"
is-promise "^2.2.2"
lru-queue "^0.1.0"
next-tick "^1.1.0"
timers-ext "^0.1.7"
merge-descriptors@1.0.1, merge-descriptors@~1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz"
@ -3793,6 +4091,11 @@ multer@^1.4.1:
type-is "^1.6.4"
xtend "^4.0.0"
mustache@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.1.0.tgz#8c1b042238a982d2eb2d30efc6c14296ae3f699d"
integrity sha512-0FsgP/WVq4mKyjolIyX+Z9Bd+3WS8GOwoUTyKXT5cTYMGeauNTi2HPCwERqseC1IHAy0Z7MDZnJBfjabd4O8GQ==
mute-stream@0.0.8, mute-stream@~0.0.4:
version "0.0.8"
resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz"
@ -3830,12 +4133,22 @@ negotiator@0.6.2:
resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz"
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
next-tick@1, next-tick@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb"
integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==
next-tick@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
nice-try@^1.0.4:
version "1.0.5"
resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
node-fetch@^2.3.0:
node-fetch@^2.3.0, node-fetch@^2.6.1:
version "2.6.1"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
@ -4304,6 +4617,11 @@ path-to-regexp@0.1.7:
resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz"
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
path-to-regexp@^2.2.1:
version "2.4.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-2.4.0.tgz#35ce7f333d5616f1c1e1bfe266c3aba2e5b2e704"
integrity sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==
path-type@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz"
@ -4588,6 +4906,11 @@ qs@~6.5.2:
resolved "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
querystring@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=
range-parser@~1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz"
@ -4693,6 +5016,11 @@ rechoir@^0.6.2:
dependencies:
resolve "^1.1.6"
regenerator-runtime@^0.13.4:
version "0.13.7"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
regex-not@^1.0.0, regex-not@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz"
@ -5506,6 +5834,14 @@ time-zone@^1.0.0:
resolved "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz"
integrity sha1-mcW/VZWJZq9tBtg73zgA3IL67F0=
timers-ext@^0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.7.tgz#6f57ad8578e07a3fb9f91d9387d65647555e25c6"
integrity sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==
dependencies:
es5-ext "~0.10.46"
next-tick "1"
tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz"
@ -5568,6 +5904,13 @@ tough-cookie@~2.5.0:
psl "^1.1.28"
punycode "^2.1.1"
tr46@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=
dependencies:
punycode "^2.1.0"
trim-off-newlines@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz"
@ -5654,6 +5997,16 @@ type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
type@^1.0.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0"
integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==
type@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/type/-/type-2.1.0.tgz#9bdc22c648cf8cf86dd23d32336a41cfb6475e3f"
integrity sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA==
typedarray-to-buffer@^3.1.5:
version "3.1.5"
resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz"
@ -5827,11 +6180,25 @@ wcwidth@^1.0.1:
dependencies:
defaults "^1.0.3"
webidl-conversions@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
well-known-symbols@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz"
integrity sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==
whatwg-url@^6.5.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8"
integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==
dependencies:
lodash.sortby "^4.7.0"
tr46 "^1.0.1"
webidl-conversions "^4.0.2"
when@~2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/when/-/when-2.0.1.tgz"