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"],
|
"extends": ["airbnb-base", "prettier"],
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": "2018"
|
"ecmaVersion": 2019
|
||||||
},
|
},
|
||||||
"plugins": ["prettier"],
|
"plugins": ["prettier"],
|
||||||
"root": true,
|
"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 ProjectStore = require('./project-store');
|
||||||
const TagStore = require('./tag-store');
|
const TagStore = require('./tag-store');
|
||||||
const TagTypeStore = require('./tag-type-store');
|
const TagTypeStore = require('./tag-type-store');
|
||||||
|
const AddonStore = require('./addon-store');
|
||||||
|
|
||||||
module.exports.createStores = (config, eventBus) => {
|
module.exports.createStores = (config, eventBus) => {
|
||||||
const { getLogger } = config;
|
const { getLogger } = config;
|
||||||
@ -50,5 +51,6 @@ module.exports.createStores = (config, eventBus) => {
|
|||||||
projectStore: new ProjectStore(db, getLogger),
|
projectStore: new ProjectStore(db, getLogger),
|
||||||
tagStore: new TagStore(db, eventBus, getLogger),
|
tagStore: new TagStore(db, eventBus, getLogger),
|
||||||
tagTypeStore: new TagTypeStore(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) {
|
if (event.type === APPLICATION_CREATED) {
|
||||||
return 'application';
|
return 'application';
|
||||||
}
|
}
|
||||||
throw new Error(`unknown event type: ${JSON.stringify(event)}`);
|
return event.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupByBaseTypeAndName(events) {
|
function groupByBaseTypeAndName(events) {
|
||||||
|
@ -4,15 +4,15 @@ const test = require('ava');
|
|||||||
const eventDiffer = require('./event-differ');
|
const eventDiffer = require('./event-differ');
|
||||||
const { FEATURE_CREATED, FEATURE_UPDATED } = require('./event-type');
|
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 = [
|
const events = [
|
||||||
{ type: FEATURE_CREATED, data: {} },
|
{ type: FEATURE_CREATED, data: {} },
|
||||||
{ type: 'unknown-type', 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 => {
|
test('diffs a feature-update event', t => {
|
||||||
|
@ -28,4 +28,7 @@ module.exports = {
|
|||||||
TAG_TYPE_CREATED: 'tag-type-created',
|
TAG_TYPE_CREATED: 'tag-type-created',
|
||||||
TAG_TYPE_DELETED: 'tag-type-deleted',
|
TAG_TYPE_DELETED: 'tag-type-deleted',
|
||||||
TAG_TYPE_UPDATED: 'tag-type-updated',
|
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 CREATE_PROJECT = 'CREATE_PROJECT';
|
||||||
const UPDATE_PROJECT = 'UPDATE_PROJECT';
|
const UPDATE_PROJECT = 'UPDATE_PROJECT';
|
||||||
const DELETE_PROJECT = 'DELETE_PROJECT';
|
const DELETE_PROJECT = 'DELETE_PROJECT';
|
||||||
|
const CREATE_ADDON = 'CREATE_ADDON';
|
||||||
|
const UPDATE_ADDON = 'UPDATE_ADDON';
|
||||||
|
const DELETE_ADDON = 'DELETE_ADDON';
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ADMIN,
|
ADMIN,
|
||||||
@ -32,4 +35,7 @@ module.exports = {
|
|||||||
CREATE_PROJECT,
|
CREATE_PROJECT,
|
||||||
UPDATE_PROJECT,
|
UPDATE_PROJECT,
|
||||||
DELETE_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 StateController = require('./state');
|
||||||
const TagController = require('./tag');
|
const TagController = require('./tag');
|
||||||
const TagTypeController = require('./tag-type');
|
const TagTypeController = require('./tag-type');
|
||||||
|
const AddonController = require('./addon');
|
||||||
const apiDef = require('./api-def.json');
|
const apiDef = require('./api-def.json');
|
||||||
|
|
||||||
class AdminApi extends Controller {
|
class AdminApi extends Controller {
|
||||||
@ -56,6 +57,7 @@ class AdminApi extends Controller {
|
|||||||
'/tag-types',
|
'/tag-types',
|
||||||
new TagTypeController(config, services).router,
|
new TagTypeController(config, services).router,
|
||||||
);
|
);
|
||||||
|
this.app.use('/addons', new AddonController(config, services).router);
|
||||||
}
|
}
|
||||||
|
|
||||||
index(req, res) {
|
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 TagTypeService = require('./tag-type-service');
|
||||||
const TagService = require('./tag-service');
|
const TagService = require('./tag-service');
|
||||||
const StrategyService = require('./strategy-service');
|
const StrategyService = require('./strategy-service');
|
||||||
|
const AddonService = require('./addon-service');
|
||||||
|
|
||||||
module.exports.createServices = (stores, config) => ({
|
module.exports.createServices = (stores, config) => {
|
||||||
featureToggleService: new FeatureToggleService(stores, config),
|
const featureToggleService = new FeatureToggleService(stores, config);
|
||||||
projectService: new ProjectService(stores, config),
|
const projectService = new ProjectService(stores, config);
|
||||||
stateService: new StateService(stores, config),
|
const stateService = new StateService(stores, config);
|
||||||
strategyService: new StrategyService(stores, config),
|
const strategyService = new StrategyService(stores, config);
|
||||||
tagTypeService: new TagTypeService(stores, config),
|
const tagTypeService = new TagTypeService(stores, config);
|
||||||
tagService: new TagService(stores, config),
|
const tagService = new TagService(stores, config);
|
||||||
clientMetricsService: new ClientMetricsService(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",
|
"js-yaml": "^3.14.0",
|
||||||
"knex": "0.21.15",
|
"knex": "0.21.15",
|
||||||
"log4js": "^6.0.0",
|
"log4js": "^6.0.0",
|
||||||
|
"memoizee": "^0.4.15",
|
||||||
"mime": "^2.4.2",
|
"mime": "^2.4.2",
|
||||||
"moment": "^2.24.0",
|
"moment": "^2.24.0",
|
||||||
"multer": "^1.4.1",
|
"multer": "^1.4.1",
|
||||||
|
"mustache": "^4.1.0",
|
||||||
|
"node-fetch": "^2.6.1",
|
||||||
"parse-database-url": "^0.3.0",
|
"parse-database-url": "^0.3.0",
|
||||||
"pg": "^8.0.3",
|
"pg": "^8.0.3",
|
||||||
"pkginfo": "^0.4.1",
|
"pkginfo": "^0.4.1",
|
||||||
@ -99,6 +102,7 @@
|
|||||||
"eslint-config-prettier": "^6.10.1",
|
"eslint-config-prettier": "^6.10.1",
|
||||||
"eslint-plugin-import": "^2.20.2",
|
"eslint-plugin-import": "^2.20.2",
|
||||||
"eslint-plugin-prettier": "^3.1.3",
|
"eslint-plugin-prettier": "^3.1.3",
|
||||||
|
"fetch-mock": "^9.11.0",
|
||||||
"husky": "^4.2.3",
|
"husky": "^4.2.3",
|
||||||
"lint-staged": "^10.0.7",
|
"lint-staged": "^10.0.7",
|
||||||
"lolex": "^6.0.0",
|
"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('projects').del(),
|
||||||
stores.db('tags').del(),
|
stores.db('tags').del(),
|
||||||
stores.db('tag_types').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();
|
await db.destroy();
|
||||||
const stores = await createStores(options, eventBus);
|
const stores = await createStores(options, eventBus);
|
||||||
stores.clientMetricsStore.setMaxListeners(0);
|
stores.clientMetricsStore.setMaxListeners(0);
|
||||||
|
stores.eventStore.setMaxListeners(0);
|
||||||
await resetDatabase(stores);
|
await resetDatabase(stores);
|
||||||
await setupDatabase(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';
|
'use strict';
|
||||||
|
|
||||||
module.exports = () => {
|
const { EventEmitter } = require('events');
|
||||||
const events = [];
|
|
||||||
|
|
||||||
return {
|
class EventStore extends EventEmitter {
|
||||||
store: event => {
|
constructor() {
|
||||||
events.push(event);
|
super();
|
||||||
return Promise.resolve();
|
this.setMaxListeners(0);
|
||||||
},
|
this.events = [];
|
||||||
getEvents: () => Promise.resolve(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 clientApplicationsStore = require('./fake-client-applications-store');
|
||||||
const featureToggleStore = require('./fake-feature-toggle-store');
|
const featureToggleStore = require('./fake-feature-toggle-store');
|
||||||
const tagStore = require('./fake-tag-store');
|
const tagStore = require('./fake-tag-store');
|
||||||
|
const tagTypeStore = require('./fake-tag-type-store');
|
||||||
const eventStore = require('./fake-event-store');
|
const eventStore = require('./fake-event-store');
|
||||||
const strategyStore = require('./fake-strategies-store');
|
const strategyStore = require('./fake-strategies-store');
|
||||||
const contextFieldStore = require('./fake-context-store');
|
const contextFieldStore = require('./fake-context-store');
|
||||||
const settingStore = require('./fake-setting-store');
|
const settingStore = require('./fake-setting-store');
|
||||||
|
const addonStore = require('./fake-addon-store');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createStores: () => {
|
createStores: () => {
|
||||||
@ -25,10 +27,12 @@ module.exports = {
|
|||||||
clientInstanceStore: clientInstanceStore(),
|
clientInstanceStore: clientInstanceStore(),
|
||||||
featureToggleStore: featureToggleStore(),
|
featureToggleStore: featureToggleStore(),
|
||||||
tagStore: tagStore(),
|
tagStore: tagStore(),
|
||||||
|
tagTypeStore: tagTypeStore(),
|
||||||
eventStore: eventStore(),
|
eventStore: eventStore(),
|
||||||
strategyStore: strategyStore(),
|
strategyStore: strategyStore(),
|
||||||
contextFieldStore: contextFieldStore(),
|
contextFieldStore: contextFieldStore(),
|
||||||
settingStore: settingStore(),
|
settingStore: settingStore(),
|
||||||
|
addonStore: addonStore(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -8,6 +8,15 @@
|
|||||||
"activation_strategy": {
|
"activation_strategy": {
|
||||||
"title": "Activation Strategies"
|
"title": "Activation Strategies"
|
||||||
},
|
},
|
||||||
|
"addons": {
|
||||||
|
"title": "Unleash Addons"
|
||||||
|
},
|
||||||
|
"addons/webhook": {
|
||||||
|
"title": "Unleash Webhook addon"
|
||||||
|
},
|
||||||
|
"api/admin/addons": {
|
||||||
|
"title": "/api/admin/addons"
|
||||||
|
},
|
||||||
"api/admin/events": {
|
"api/admin/events": {
|
||||||
"title": "/api/admin/events"
|
"title": "/api/admin/events"
|
||||||
},
|
},
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
"securing_unleash",
|
"securing_unleash",
|
||||||
"unleash_context",
|
"unleash_context",
|
||||||
"activation_strategy",
|
"activation_strategy",
|
||||||
|
"addons",
|
||||||
"client_specification",
|
"client_specification",
|
||||||
"migration_guide"
|
"migration_guide"
|
||||||
],
|
],
|
||||||
@ -36,7 +37,8 @@
|
|||||||
"api/admin/metrics",
|
"api/admin/metrics",
|
||||||
"api/admin/events",
|
"api/admin/events",
|
||||||
"api/admin/state",
|
"api/admin/state",
|
||||||
"api/admin/feature-types"
|
"api/admin/feature-types",
|
||||||
|
"api/admin/addons"
|
||||||
],
|
],
|
||||||
"Internal": ["api/internal"],
|
"Internal": ["api/internal"],
|
||||||
"Specification": ["api/open_api"]
|
"Specification": ["api/open_api"]
|
||||||
|
369
yarn.lock
369
yarn.lock
@ -9,6 +9,34 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/highlight" "^7.10.4"
|
"@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":
|
"@babel/core@^7.7.5":
|
||||||
version "7.11.1"
|
version "7.11.1"
|
||||||
resolved "https://registry.npmjs.org/@babel/core/-/core-7.11.1.tgz"
|
resolved "https://registry.npmjs.org/@babel/core/-/core-7.11.1.tgz"
|
||||||
@ -40,6 +68,15 @@
|
|||||||
jsesc "^2.5.1"
|
jsesc "^2.5.1"
|
||||||
source-map "^0.5.0"
|
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":
|
"@babel/helper-function-name@^7.10.4":
|
||||||
version "7.10.4"
|
version "7.10.4"
|
||||||
resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz"
|
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/template" "^7.10.4"
|
||||||
"@babel/types" "^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":
|
"@babel/helper-get-function-arity@^7.10.4":
|
||||||
version "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"
|
resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz"
|
||||||
@ -56,6 +102,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.10.4"
|
"@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":
|
"@babel/helper-member-expression-to-functions@^7.10.4":
|
||||||
version "7.11.0"
|
version "7.11.0"
|
||||||
resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz"
|
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:
|
dependencies:
|
||||||
"@babel/types" "^7.11.0"
|
"@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":
|
"@babel/helper-module-imports@^7.10.4":
|
||||||
version "7.10.4"
|
version "7.10.4"
|
||||||
resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz"
|
resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz"
|
||||||
@ -70,6 +130,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.10.4"
|
"@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":
|
"@babel/helper-module-transforms@^7.11.0":
|
||||||
version "7.11.0"
|
version "7.11.0"
|
||||||
resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz"
|
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"
|
"@babel/types" "^7.11.0"
|
||||||
lodash "^4.17.19"
|
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":
|
"@babel/helper-optimise-call-expression@^7.10.4":
|
||||||
version "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"
|
resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz"
|
||||||
@ -90,6 +172,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.10.4"
|
"@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":
|
"@babel/helper-replace-supers@^7.10.4":
|
||||||
version "7.10.4"
|
version "7.10.4"
|
||||||
resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz"
|
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/traverse" "^7.10.4"
|
||||||
"@babel/types" "^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":
|
"@babel/helper-simple-access@^7.10.4":
|
||||||
version "7.10.4"
|
version "7.10.4"
|
||||||
resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz"
|
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/template" "^7.10.4"
|
||||||
"@babel/types" "^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":
|
"@babel/helper-split-export-declaration@^7.11.0":
|
||||||
version "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"
|
resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz"
|
||||||
@ -115,11 +221,23 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.11.0"
|
"@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":
|
"@babel/helper-validator-identifier@^7.10.4":
|
||||||
version "7.10.4"
|
version "7.10.4"
|
||||||
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz"
|
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz"
|
||||||
integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==
|
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":
|
"@babel/helpers@^7.10.4":
|
||||||
version "7.10.4"
|
version "7.10.4"
|
||||||
resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz"
|
resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz"
|
||||||
@ -129,6 +247,15 @@
|
|||||||
"@babel/traverse" "^7.10.4"
|
"@babel/traverse" "^7.10.4"
|
||||||
"@babel/types" "^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":
|
"@babel/highlight@^7.10.4":
|
||||||
version "7.10.4"
|
version "7.10.4"
|
||||||
resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz"
|
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"
|
resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.2.tgz"
|
||||||
integrity sha512-Vuj/+7vLo6l1Vi7uuO+1ngCDNeVmNbTngcJFKCR/oEtz8tKz0CJxZEGmPt9KcIloZhOZ3Zit6xbpXT2MDlS9Vw==
|
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":
|
"@babel/template@^7.10.4":
|
||||||
version "7.10.4"
|
version "7.10.4"
|
||||||
resolved "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz"
|
resolved "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz"
|
||||||
@ -152,6 +291,15 @@
|
|||||||
"@babel/parser" "^7.10.4"
|
"@babel/parser" "^7.10.4"
|
||||||
"@babel/types" "^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":
|
"@babel/traverse@^7.10.4", "@babel/traverse@^7.11.0":
|
||||||
version "7.11.0"
|
version "7.11.0"
|
||||||
resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.0.tgz"
|
resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.0.tgz"
|
||||||
@ -167,6 +315,21 @@
|
|||||||
globals "^11.1.0"
|
globals "^11.1.0"
|
||||||
lodash "^4.17.19"
|
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":
|
"@babel/types@^7.10.4", "@babel/types@^7.11.0":
|
||||||
version "7.11.0"
|
version "7.11.0"
|
||||||
resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.0.tgz"
|
resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.0.tgz"
|
||||||
@ -176,6 +339,15 @@
|
|||||||
lodash "^4.17.19"
|
lodash "^4.17.19"
|
||||||
to-fast-properties "^2.0.0"
|
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":
|
"@concordance/react@^2.0.0":
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.npmjs.org/@concordance/react/-/react-2.0.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz"
|
||||||
integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
|
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:
|
core-util-is@1.0.2, core-util-is@~1.0.0:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz"
|
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"
|
resolved "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz"
|
||||||
integrity sha1-IegLK+hYD5i0aPN5QwZisEbDStI=
|
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:
|
dashdash@^1.12.0:
|
||||||
version "1.14.1"
|
version "1.14.1"
|
||||||
resolved "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz"
|
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-date-object "^1.0.1"
|
||||||
is-symbol "^1.0.2"
|
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:
|
es6-error@^4.0.1:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz"
|
resolved "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz"
|
||||||
integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==
|
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:
|
es6-promise@^4.0.3:
|
||||||
version "4.2.8"
|
version "4.2.8"
|
||||||
resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz"
|
resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz"
|
||||||
@ -1715,6 +1918,24 @@ es6-promisify@^5.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
es6-promise "^4.0.3"
|
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:
|
escalade@^3.0.2:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz"
|
||||||
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
|
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:
|
event-target-shim@^5.0.0:
|
||||||
version "5.0.1"
|
version "5.0.1"
|
||||||
resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz"
|
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"
|
utils-merge "1.0.1"
|
||||||
vary "~1.1.2"
|
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:
|
extend-shallow@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz"
|
resolved "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz"
|
||||||
@ -2097,6 +2333,22 @@ fastq@^1.6.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
reusify "^1.0.4"
|
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:
|
figures@^3.0.0, figures@^3.2.0:
|
||||||
version "3.2.0"
|
version "3.2.0"
|
||||||
resolved "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz"
|
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:
|
dependencies:
|
||||||
is-glob "^4.0.1"
|
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:
|
glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
|
||||||
version "7.1.6"
|
version "7.1.6"
|
||||||
resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz"
|
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"
|
resolved "https://registry.npmjs.org/is-plain-object/-/is-plain-object-4.1.1.tgz"
|
||||||
integrity sha512-5Aw8LLVsDlZsETVMhoMXzqsXwQqr/0vlnBYzIXJbYo2F4yYlhLHs+Ez7Bod7IIQKWkJbJfxrWD7pA1Dw1TKrwA==
|
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:
|
is-promise@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz"
|
||||||
integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
|
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:
|
is-symbol@^1.0.2:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz"
|
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"
|
resolved "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz"
|
||||||
integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=
|
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:
|
lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20:
|
||||||
version "4.17.20"
|
version "4.17.20"
|
||||||
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz"
|
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz"
|
||||||
@ -3578,6 +3855,13 @@ lru-cache@^5.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist "^3.0.2"
|
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:
|
make-dir@^3.0.0, make-dir@^3.0.2:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz"
|
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"
|
map-age-cleaner "^0.1.3"
|
||||||
mimic-fn "^3.0.0"
|
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:
|
merge-descriptors@1.0.1, merge-descriptors@~1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz"
|
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"
|
type-is "^1.6.4"
|
||||||
xtend "^4.0.0"
|
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:
|
mute-stream@0.0.8, mute-stream@~0.0.4:
|
||||||
version "0.0.8"
|
version "0.0.8"
|
||||||
resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz"
|
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"
|
resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz"
|
||||||
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
|
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:
|
nice-try@^1.0.4:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz"
|
resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz"
|
||||||
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
|
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
|
||||||
|
|
||||||
node-fetch@^2.3.0:
|
node-fetch@^2.3.0, node-fetch@^2.6.1:
|
||||||
version "2.6.1"
|
version "2.6.1"
|
||||||
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz"
|
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz"
|
||||||
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
|
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"
|
resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz"
|
||||||
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
|
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:
|
path-type@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz"
|
||||||
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
|
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:
|
range-parser@~1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz"
|
resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz"
|
||||||
@ -4693,6 +5016,11 @@ rechoir@^0.6.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
resolve "^1.1.6"
|
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:
|
regex-not@^1.0.0, regex-not@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz"
|
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"
|
resolved "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz"
|
||||||
integrity sha1-mcW/VZWJZq9tBtg73zgA3IL67F0=
|
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:
|
tmp@^0.0.33:
|
||||||
version "0.0.33"
|
version "0.0.33"
|
||||||
resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz"
|
resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz"
|
||||||
@ -5568,6 +5904,13 @@ tough-cookie@~2.5.0:
|
|||||||
psl "^1.1.28"
|
psl "^1.1.28"
|
||||||
punycode "^2.1.1"
|
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:
|
trim-off-newlines@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.npmjs.org/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz"
|
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"
|
media-typer "0.3.0"
|
||||||
mime-types "~2.1.24"
|
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:
|
typedarray-to-buffer@^3.1.5:
|
||||||
version "3.1.5"
|
version "3.1.5"
|
||||||
resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz"
|
resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz"
|
||||||
@ -5827,11 +6180,25 @@ wcwidth@^1.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
defaults "^1.0.3"
|
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:
|
well-known-symbols@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz"
|
resolved "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz"
|
||||||
integrity sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==
|
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:
|
when@~2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.npmjs.org/when/-/when-2.0.1.tgz"
|
resolved "https://registry.npmjs.org/when/-/when-2.0.1.tgz"
|
||||||
|
Loading…
Reference in New Issue
Block a user