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:
parent
8f99f71156
commit
17c8fe7710
@ -4,7 +4,7 @@
|
||||
},
|
||||
"extends": ["airbnb-base", "prettier"],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "2018"
|
||||
"ecmaVersion": 2019
|
||||
},
|
||||
"plugins": ["prettier"],
|
||||
"root": true,
|
||||
|
8
docs/addons.md
Normal file
8
docs/addons.md
Normal 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
|
21
docs/addons/jira-comment.md
Normal file
21
docs/addons/jira-comment.md
Normal 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
8
docs/addons/webhook.md
Normal 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
157
docs/api/admin/addons.md
Normal 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`.
|
35
lib/addons/addon-schema.js
Normal file
35
lib/addons/addon-schema.js
Normal 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
44
lib/addons/addon.js
Normal 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
5
lib/addons/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
const webhook = require('./webhook');
|
||||
const slackAddon = require('./slack');
|
||||
const jiraAddon = require('./jira-comment');
|
||||
|
||||
module.exports = [webhook, slackAddon, jiraAddon];
|
51
lib/addons/jira-comment-definition.js
Normal file
51
lib/addons/jira-comment-definition.js
Normal 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
116
lib/addons/jira-comment.js
Normal 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;
|
230
lib/addons/jira-comment.test.js
Normal file
230
lib/addons/jira-comment.test.js
Normal 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());
|
||||
});
|
63
lib/addons/slack-definition.js
Normal file
63
lib/addons/slack-definition.js
Normal 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
109
lib/addons/slack.js
Normal 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
132
lib/addons/slack.test.js
Normal 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');
|
||||
});
|
11
lib/addons/slack.test.js.md
Normal file
11
lib/addons/slack.test.js.md
Normal 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"}]}]}'
|
BIN
lib/addons/slack.test.js.snap
Normal file
BIN
lib/addons/slack.test.js.snap
Normal file
Binary file not shown.
53
lib/addons/webhook-definition.js
Normal file
53
lib/addons/webhook-definition.js
Normal 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
39
lib/addons/webhook.js
Normal 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;
|
67
lib/addons/webhook.test.js
Normal file
67
lib/addons/webhook.test.js
Normal 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
105
lib/db/addon-store.js
Normal 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;
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
@ -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) {
|
||||
|
@ -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 => {
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
|
86
lib/routes/admin-api/addon.js
Normal file
86
lib/routes/admin-api/addon.js
Normal 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;
|
@ -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) {
|
||||
|
27
lib/services/addon-schema.js
Normal file
27
lib/services/addon-schema.js
Normal 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,
|
||||
};
|
154
lib/services/addon-service.js
Normal file
154
lib/services/addon-service.js
Normal 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;
|
261
lib/services/addon-service.test.js
Normal file
261
lib/services/addon-service.test.js
Normal 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);
|
||||
});
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
20
migrations/20210119084617-add-addon-table.js
Normal file
20
migrations/20210119084617-add-addon-table.js
Normal 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);
|
||||
};
|
@ -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",
|
||||
|
209
test/e2e/api/admin/addon.e2e.test.js
Normal file
209
test/e2e/api/admin/addon.e2e.test.js
Normal 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);
|
||||
});
|
@ -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
25
test/fixtures/fake-addon-store.js
vendored
Normal 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),
|
||||
};
|
||||
};
|
30
test/fixtures/fake-event-store.js
vendored
30
test/fixtures/fake-event-store.js
vendored
@ -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
17
test/fixtures/fake-tag-type-store.js
vendored
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
4
test/fixtures/store.js
vendored
4
test/fixtures/store.js
vendored
@ -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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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
369
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user