1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

OpenAPI: addon operations (#3421)

This PR updates the OpenAPI schemas for all the operations tagged with
"addons". In doing so, I also uncovered a few bugs and inconsistencies.
These have also been fixed.

## Changes

I've added inline comments to the changed files to call out anything
that I think is worth clarifying specifically. As an overall
description, this PR does the following:

Splits `addon-schema` into `addon-schema` and
`addon-create-update-schema`. The former is used when describing addons
that exist within Unleash and contain IDs and `created_at` timestamps.
The latter is used when creating or updating addons.

Adds examples and descriptions to all relevant schemas (and their
dependencies).

Updates addons operations descriptions and response codes (including the
recently introduced 413 and 415).

Fixes a bug where the server would crash if it didn't recognize the
addon provider (test added).

Fixes a bug where updating an addon wouldn't return anything, even if
the API said that it would. (test added)

Resolves some inconsistencies in handling of addon description. (tests
added)

### Addon descriptions

when creating addons, descriptions are optional. The original
`addonSchema` said they could be `null | string | undefined`. This
caused some inconsistencies in return values. Sometimes they were
returned, other times not. I've made it so that `descriptions` are now
always returned from the API. If it's not defined or if it's set to
`null`, the API will return `description: null`.

### `IAddonDto`

`IAddonDto`, the type we used internally to model the incoming addons
(for create and update) says that `description` is required. This hasn't
been true at least since we introduced OpenAPI schemas. As such, the
update and insert methods that the service uses were incompatible with
the **actual** data that we require.

I've changed the type to reflect reality for now. Assuming the tests
pass, this **should** all be good, but I'd like the reviewer(s) to give
this a think too.

---------

Co-authored-by: Christopher Kolstad <chriswk@getunleash.ai>
This commit is contained in:
Thomas Heartman 2023-04-18 12:50:34 +02:00 committed by GitHub
parent d3b8056ed6
commit e11fae56e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1128 additions and 47 deletions

View File

@ -16,6 +16,7 @@ const COLUMNS = [
'events',
'projects',
'environments',
'created_at',
];
const TABLE = 'addons';
@ -71,18 +72,19 @@ export default class AddonStore implements IAddonStore {
stopTimer();
// eslint-disable-next-line @typescript-eslint/naming-convention
const { id, created_at } = rows[0];
return { id, createdAt: created_at, ...addon };
return this.rowToAddon({ id, createdAt: created_at, ...addon });
}
async update(id: number, addon: IAddonDto): Promise<IAddon> {
const rows = await this.db(TABLE)
.where({ id })
.update(this.addonToRow(addon));
.update(this.addonToRow(addon))
.returning(COLUMNS);
if (!rows) {
throw new NotFoundError('Could not find addon');
}
return rows[0];
return this.rowToAddon(rows[0]);
}
async delete(id: number): Promise<void> {
@ -114,7 +116,7 @@ export default class AddonStore implements IAddonStore {
id: row.id,
provider: row.provider,
enabled: row.enabled,
description: row.description,
description: row.description ?? null,
parameters: row.parameters,
events: row.events,
projects: row.projects || [],

View File

@ -3,6 +3,7 @@ import {
adminFeaturesQuerySchema,
addonParameterSchema,
addonSchema,
addonCreateUpdateSchema,
addonsSchema,
addonTypeSchema,
apiTokenSchema,
@ -181,6 +182,7 @@ export const schemas: UnleashSchemas = {
adminFeaturesQuerySchema,
addonParameterSchema,
addonSchema,
addonCreateUpdateSchema,
addonsSchema,
addonTypeSchema,
apiTokenSchema,

View File

@ -79,10 +79,6 @@ const metaRules: Rule[] = [
},
},
knownExceptions: [
'addonParameterSchema',
'addonSchema',
'addonsSchema',
'addonTypeSchema',
'apiTokenSchema',
'apiTokensSchema',
'applicationSchema',
@ -202,10 +198,6 @@ const metaRules: Rule[] = [
},
knownExceptions: [
'adminFeaturesQuerySchema',
'addonParameterSchema',
'addonSchema',
'addonsSchema',
'addonTypeSchema',
'apiTokenSchema',
'apiTokensSchema',
'applicationSchema',

View File

@ -0,0 +1,79 @@
import { FromSchema } from 'json-schema-to-ts';
export const addonCreateUpdateSchema = {
$id: '#/components/schemas/addonCreateUpdateSchema',
type: 'object',
required: ['provider', 'enabled', 'parameters', 'events'],
description:
'Data required to create or update an [Unleash addon](https://docs.getunleash.io/reference/addons) instance.',
properties: {
provider: {
type: 'string',
description: `The addon provider, such as "webhook" or "slack". This string is **case sensitive** and maps to the provider's \`name\` property.
The list of all supported providers and their parameters for a specific Unleash instance can be found by making a GET request to the \`api/admin/addons\` endpoint: the \`providers\` property of that response will contain all available providers.
The default set of providers can be found in the [addons reference documentation](https://docs.getunleash.io/reference/addons). The default supported options are:
- \`datadog\` for [Datadog](https://docs.getunleash.io/reference/addons/datadog)
- \`slack\` for [Slack](https://docs.getunleash.io/reference/addons/slack)
- \`teams\` for [Microsoft Teams](https://docs.getunleash.io/reference/addons/teams)
- \`webhook\` for [webhooks](https://docs.getunleash.io/reference/addons/webhook)
The provider you choose for your addon dictates what properties the \`parameters\` object needs. Refer to the documentation for each provider for more information.
`,
example: 'webhook',
},
description: {
type: 'string',
description: 'A description of the addon.',
example:
'This addon posts updates to our internal feature tracking system whenever a feature is created or updated.',
},
enabled: {
type: 'boolean',
description: 'Whether the addon should be enabled or not.',
},
parameters: {
type: 'object',
additionalProperties: {},
example: {
url: 'http://localhost:4242/webhook',
},
description:
'Parameters for the addon provider. This object has different required and optional properties depending on the provider you choose. Consult the documentation for details.',
},
events: {
type: 'array',
description:
'The event types that will trigger this specific addon.',
items: {
type: 'string',
},
example: ['feature-created', 'feature-updated'],
},
projects: {
type: 'array',
description:
'The projects that this addon will listen to events from. An empty list means it will listen to events from **all** projects.',
example: ['new-landing-project', 'signups-v2'],
items: {
type: 'string',
},
},
environments: {
type: 'array',
description:
'The list of environments that this addon will listen to events from. An empty list means it will listen to events from **all** environments.',
example: ['development', 'production'],
items: {
type: 'string',
},
},
},
components: {},
} as const;
export type AddonCreateUpdateSchema = FromSchema<
typeof addonCreateUpdateSchema
>;

View File

@ -3,28 +3,51 @@ import { FromSchema } from 'json-schema-to-ts';
export const addonParameterSchema = {
$id: '#/components/schemas/addonParameterSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'displayName', 'type', 'required', 'sensitive'],
description: 'An addon parameter definition.',
properties: {
name: {
type: 'string',
example: 'emojiIcon',
description:
'The name of the parameter as it is used in code. References to this parameter should use this value.',
},
displayName: {
type: 'string',
example: 'Emoji Icon',
description:
'The name of the parameter as it is shown to the end user in the Admin UI.',
},
type: {
type: 'string',
description:
'The type of the parameter. Corresponds roughly to [HTML `input` field types](https://developer.mozilla.org/docs/Web/HTML/Element/Input#input_types). Multi-line inut fields are indicated as `textfield` (equivalent to the HTML `textarea` tag).',
example: 'text',
},
description: {
type: 'string',
example:
'The emoji_icon to use when posting messages to slack. Defaults to ":unleash:".',
description:
'A description of the parameter. This should explain to the end user what the parameter is used for.',
},
placeholder: {
type: 'string',
example: ':unleash:',
description:
'The default value for this parameter. This value is used if no other value is provided.',
},
required: {
type: 'boolean',
example: false,
description:
'Whether this parameter is required or not. If a parameter is required, you must give it a value when you create the addon. If it is not required it can be left out. It may receive a default value in those cases.',
},
sensitive: {
type: 'boolean',
example: false,
description: `Indicates whether this parameter is **sensitive** or not. Unleash will not return sensitive parameters to API requests. It will instead use a number of asterisks to indicate that a value is set, e.g. "******". The number of asterisks does not correlate to the parameter\'s value.`,
},
},
components: {},

View File

@ -5,6 +5,8 @@ test('addonSchema', () => {
const data: AddonSchema = {
provider: 'some-provider',
enabled: true,
description: null,
id: 5,
parameters: {
someKey: 'some-value',
},

View File

@ -3,43 +3,70 @@ import { FromSchema } from 'json-schema-to-ts';
export const addonSchema = {
$id: '#/components/schemas/addonSchema',
type: 'object',
required: ['provider', 'enabled', 'parameters', 'events'],
description: `An [addon](https://docs.getunleash.io/reference/addons) instance description. Contains data about what kind of provider it uses, whether it's enabled or not, what events it listens for, and more.`,
required: [
'id',
'description',
'provider',
'enabled',
'parameters',
'events',
],
properties: {
id: {
type: 'number',
},
createdAt: {
type: 'string',
format: 'date-time',
nullable: true,
type: 'integer',
minimum: 1,
example: 27,
description: "The addon's unique identifier.",
},
provider: {
type: 'string',
description: `The addon provider, such as "webhook" or "slack".`,
example: 'webhook',
},
description: {
type: 'string',
description:
'A description of the addon. `null` if no description exists.',
example:
'This addon posts updates to our internal feature tracking system whenever a feature is created or updated.',
nullable: true,
},
enabled: {
type: 'boolean',
description: 'Whether the addon is enabled or not.',
},
parameters: {
type: 'object',
additionalProperties: true,
additionalProperties: {},
example: {
url: 'http://localhost:4242/webhook',
},
description:
'Parameters for the addon provider. This object has different required and optional properties depending on the provider you choose.',
},
events: {
type: 'array',
description: 'The event types that trigger this specific addon.',
items: {
type: 'string',
},
example: ['feature-created', 'feature-updated'],
},
projects: {
type: 'array',
description:
'The projects that this addon listens to events from. An empty list means it listens to events from **all** projects.',
example: ['new-landing-project', 'signups-v2'],
items: {
type: 'string',
},
},
environments: {
type: 'array',
description:
'The list of environments that this addon listens to events from. An empty list means it listens to events from **all** environments.',
example: ['development', 'production'],
items: {
type: 'string',
},

View File

@ -5,37 +5,119 @@ import { tagTypeSchema } from './tag-type-schema';
export const addonTypeSchema = {
$id: '#/components/schemas/addonTypeSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'displayName', 'documentationUrl', 'description'],
description:
'An addon provider. Defines a specific addon type and what the end user must configure when creating a new addon of that type.',
properties: {
name: {
type: 'string',
description:
"The name of the addon type. When creating new addons, this goes in the payload's `type` field.",
example: 'slack',
},
displayName: {
type: 'string',
description:
"The addon type's name as it should be displayed in the admin UI.",
example: 'Slack',
},
documentationUrl: {
type: 'string',
description:
'A URL to where you can find more information about using this addon type.',
example: 'https://docs.getunleash.io/docs/addons/slack',
},
description: {
type: 'string',
description: 'A description of the addon type.',
example: 'Allows Unleash to post updates to Slack.',
},
tagTypes: {
type: 'array',
description: `A list of [Unleash tag types](https://docs.getunleash.io/reference/tags#tag-types) that this addon uses. These tags will be added to the Unleash instance when an addon of this type is created.`,
example: [
{
name: 'slack',
description:
'Slack tag used by the slack-addon to specify the slack channel.',
icon: 'S',
},
],
items: {
$ref: '#/components/schemas/tagTypeSchema',
},
},
parameters: {
type: 'array',
description:
"The addon provider's parameters. Use these to configure an addon of this provider type. Items with `required: true` must be provided.",
items: {
$ref: '#/components/schemas/addonParameterSchema',
},
example: [
{
name: 'url',
displayName: 'Slack webhook URL',
description: '(Required)',
type: 'url',
required: true,
sensitive: true,
},
{
name: 'username',
displayName: 'Username',
placeholder: 'Unleash',
description:
'The username to use when posting messages to slack. Defaults to "Unleash".',
type: 'text',
required: false,
sensitive: 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,
sensitive: false,
},
{
name: 'defaultChannel',
displayName: 'Default channel',
description:
'(Required) Default channel to post updates to if not specified in the slack-tag',
type: 'text',
required: true,
sensitive: false,
},
],
},
events: {
type: 'array',
description:
'All the [event types](https://docs.getunleash.io/reference/api/legacy/unleash/admin/events#feature-toggle-events) that are available for this addon provider.',
items: {
type: 'string',
},
example: [
'feature-created',
'feature-updated',
'feature-archived',
'feature-revived',
'feature-stale-on',
'feature-stale-off',
'feature-environment-enabled',
'feature-environment-disabled',
'feature-strategy-remove',
'feature-strategy-update',
'feature-strategy-add',
'feature-metadata-updated',
'feature-variants-updated',
'feature-project-change',
],
},
},
components: {

View File

@ -9,6 +9,8 @@ test('addonsSchema', () => {
events: ['some-event'],
enabled: true,
provider: 'some-name',
description: null,
id: 5,
},
],
providers: [

View File

@ -8,15 +8,256 @@ export const addonsSchema = {
$id: '#/components/schemas/addonsSchema',
type: 'object',
required: ['addons', 'providers'],
description: `An object containing two things:
1. A list of all [addons](https://docs.getunleash.io/reference/addons) defined on this Unleash instance
2. A list of all addon providers defined on this instance`,
properties: {
addons: {
type: 'array',
description:
'All the addons that exist on this instance of Unleash.',
items: {
$ref: '#/components/schemas/addonSchema',
},
},
providers: {
type: 'array',
description:
'A list of all available addon providers, along with their parameters and descriptions.',
example: [
{
name: 'webhook',
displayName: 'Webhook',
description:
'A Webhook is a generic way to post messages from Unleash to third party services.',
documentationUrl:
'https://docs.getunleash.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,
sensitive: 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,
sensitive: false,
},
{
name: 'authorization',
displayName: 'Authorization',
placeholder: '',
description:
'(Optional) The Authorization header to use. Not used if left blank.',
type: 'text',
required: false,
sensitive: true,
},
{
name: 'bodyTemplate',
displayName: 'Body template',
placeholder:
'{\n "event": "{{event.type}}",\n "createdBy": "{{event.createdBy}}",\n "featureToggle": "{{event.data.name}}",\n "timestamp": "{{event.data.createdAt}}"\n}',
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://docs.getunleash.io/reference/api/legacy/unleash/admin/events)",
type: 'textfield',
required: false,
sensitive: false,
},
],
events: [
'feature-created',
'feature-updated',
'feature-archived',
'feature-revived',
'feature-stale-on',
'feature-stale-off',
'feature-environment-enabled',
'feature-environment-disabled',
'feature-strategy-remove',
'feature-strategy-update',
'feature-strategy-add',
'feature-metadata-updated',
'feature-variants-updated',
'feature-project-change',
'feature-tagged',
'feature-untagged',
'change-request-created',
'change-request-discarded',
'change-added',
'change-discarded',
'change-request-approved',
'change-request-approval-added',
'change-request-cancelled',
'change-request-sent-to-review',
'change-request-applied',
],
},
{
name: 'slack',
displayName: 'Slack',
description: 'Allows Unleash to post updates to Slack.',
documentationUrl:
'https://docs.getunleash.io/docs/addons/slack',
parameters: [
{
name: 'url',
displayName: 'Slack webhook URL',
description: '(Required)',
type: 'url',
required: true,
sensitive: true,
},
{
name: 'username',
displayName: 'Username',
placeholder: 'Unleash',
description:
'The username to use when posting messages to slack. Defaults to "Unleash".',
type: 'text',
required: false,
sensitive: 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,
sensitive: false,
},
{
name: 'defaultChannel',
displayName: 'Default channel',
description:
'(Required) Default channel to post updates to if not specified in the slack-tag',
type: 'text',
required: true,
sensitive: false,
},
],
events: [
'feature-created',
'feature-updated',
'feature-archived',
'feature-revived',
'feature-stale-on',
'feature-stale-off',
'feature-environment-enabled',
'feature-environment-disabled',
'feature-strategy-remove',
'feature-strategy-update',
'feature-strategy-add',
'feature-metadata-updated',
'feature-variants-updated',
'feature-project-change',
],
tagTypes: [
{
name: 'slack',
description:
'Slack tag used by the slack-addon to specify the slack channel.',
icon: 'S',
},
],
},
{
name: 'teams',
displayName: 'Microsoft Teams',
description:
'Allows Unleash to post updates to Microsoft Teams.',
documentationUrl:
'https://docs.getunleash.io/docs/addons/teams',
parameters: [
{
name: 'url',
displayName: 'Microsoft Teams webhook URL',
description: '(Required)',
type: 'url',
required: true,
sensitive: true,
},
],
events: [
'feature-created',
'feature-updated',
'feature-archived',
'feature-revived',
'feature-stale-on',
'feature-stale-off',
'feature-environment-enabled',
'feature-environment-disabled',
'feature-strategy-remove',
'feature-strategy-update',
'feature-strategy-add',
'feature-metadata-updated',
'feature-variants-updated',
'feature-project-change',
],
},
{
name: 'datadog',
displayName: 'Datadog',
description: 'Allows Unleash to post updates to Datadog.',
documentationUrl:
'https://docs.getunleash.io/docs/addons/datadog',
parameters: [
{
name: 'url',
displayName: 'Datadog Events URL',
description:
'Default url: https://api.datadoghq.com/api/v1/events. Needs to be changed if your not using the US1 site.',
type: 'url',
required: false,
sensitive: false,
},
{
name: 'apiKey',
displayName: 'Datadog API key',
placeholder: 'j96c23b0f12a6b3434a8d710110bd862',
description: '(Required) API key from Datadog',
type: 'text',
required: true,
sensitive: true,
},
],
events: [
'feature-created',
'feature-updated',
'feature-archived',
'feature-revived',
'feature-stale-on',
'feature-stale-off',
'feature-environment-enabled',
'feature-environment-disabled',
'feature-strategy-remove',
'feature-strategy-update',
'feature-strategy-add',
'feature-metadata-updated',
'feature-project-change',
'feature-variants-updated',
],
tagTypes: [
{
name: 'datadog',
description:
'All Datadog tags added to a specific feature are sent to datadog event stream.',
icon: 'D',
},
],
},
],
items: {
$ref: '#/components/schemas/addonTypeSchema',
},

View File

@ -9,6 +9,7 @@ export * from './role-schema';
export * from './tags-schema';
export * from './user-schema';
export * from './addon-schema';
export * from './addon-create-update-schema';
export * from './email-schema';
export * from './event-schema';
export * from './group-schema';

View File

@ -18,7 +18,11 @@ import { OpenApiService } from '../../services/openapi-service';
import { AddonSchema, addonSchema } from '../../openapi/spec/addon-schema';
import { serializeDates } from '../../types/serialize-dates';
import { AddonsSchema, addonsSchema } from '../../openapi/spec/addons-schema';
import { emptyResponse } from '../../openapi/util/standard-responses';
import {
emptyResponse,
getStandardResponses,
} from '../../openapi/util/standard-responses';
import { AddonCreateUpdateSchema } from 'lib/openapi/spec/addon-create-update-schema';
type AddonServices = Pick<IUnleashServices, 'addonService' | 'openApiService'>;
@ -47,9 +51,13 @@ class AddonController extends Controller {
handler: this.getAddons,
middleware: [
openApiService.validPath({
summary: 'Get all addons and providers',
description:
'Retrieve all addons and providers that are defined on this Unleash instance.',
tags: ['Addons'],
operationId: 'getAddons',
responses: {
...getStandardResponses(401),
200: createResponseSchema('addonsSchema'),
},
}),
@ -63,10 +71,16 @@ class AddonController extends Controller {
permission: CREATE_ADDON,
middleware: [
openApiService.validPath({
summary: 'Create a new addon',
description:
'Create an addon instance. The addon must use one of the providers available on this Unleash instance.',
tags: ['Addons'],
operationId: 'createAddon',
requestBody: createRequestSchema('addonSchema'),
responses: { 200: createResponseSchema('addonSchema') },
requestBody: createRequestSchema('addonCreateUpdateSchema'),
responses: {
200: createResponseSchema('addonSchema'),
...getStandardResponses(400, 401, 403, 413, 415),
},
}),
],
});
@ -78,9 +92,15 @@ class AddonController extends Controller {
permission: NONE,
middleware: [
openApiService.validPath({
summary: 'Get a specific addon',
description:
'Retrieve information about the addon whose ID matches the ID in the request URL.',
tags: ['Addons'],
operationId: 'getAddon',
responses: { 200: createResponseSchema('addonSchema') },
responses: {
200: createResponseSchema('addonSchema'),
...getStandardResponses(401),
},
}),
],
});
@ -92,10 +112,17 @@ class AddonController extends Controller {
permission: UPDATE_ADDON,
middleware: [
openApiService.validPath({
summary: 'Update an addon',
description: `Update the addon with a specific ID. Any fields in the update object will be updated. Properties that are not included in the update object will not be affected. To empty a property, pass \`null\` as that property's value.
Note: passing \`null\` as a value for the description property will set it to an empty string.`,
tags: ['Addons'],
operationId: 'updateAddon',
requestBody: createRequestSchema('addonSchema'),
responses: { 200: createResponseSchema('addonSchema') },
requestBody: createRequestSchema('addonCreateUpdateSchema'),
responses: {
200: createResponseSchema('addonSchema'),
...getStandardResponses(400, 401, 403, 413, 415),
},
}),
],
});
@ -108,9 +135,15 @@ class AddonController extends Controller {
permission: DELETE_ADDON,
middleware: [
openApiService.validPath({
summary: 'Delete an addon',
description:
'Delete the addon specified by the ID in the request path.',
tags: ['Addons'],
operationId: 'deleteAddon',
responses: { 200: emptyResponse },
responses: {
200: emptyResponse,
...getStandardResponses(401, 403),
},
}),
],
});
@ -141,7 +174,7 @@ class AddonController extends Controller {
}
async updateAddon(
req: IAuthRequest<{ id: number }, any, any, any>,
req: IAuthRequest<{ id: number }, any, AddonCreateUpdateSchema, any>,
res: Response<AddonSchema>,
): Promise<void> {
const { id } = req.params;
@ -159,7 +192,7 @@ class AddonController extends Controller {
}
async createAddon(
req: IAuthRequest<AddonSchema, any, any, any>,
req: IAuthRequest<AddonCreateUpdateSchema, any, any, any>,
res: Response<AddonSchema>,
): Promise<void> {
const createdBy = extractUsername(req);

View File

@ -70,6 +70,8 @@ export const handleErrors: (
return res.status(400).json(error).end();
case 'ProjectWithoutOwnerError':
return res.status(409).json(error).end();
case 'TypeError':
return res.status(400).json(error).end();
default:
logger.error('Server failed executing request', error);
return res.status(500).end();

View File

@ -73,7 +73,7 @@ test('should not allow addon-config for unknown provider', async () => {
},
'test',
);
}).rejects.toThrow(new TypeError('Unknown addon provider unknown'));
}).rejects.toThrow(ValidationError);
});
test('should trigger simple-addon eventHandler', async () => {

View File

@ -250,9 +250,21 @@ export default class AddonService {
}
async validateKnownProvider(config: Partial<IAddonDto>): Promise<boolean> {
if (!config.provider) {
throw new ValidationError(
'No addon provider supplied. The property was either missing or an empty value.',
[],
undefined,
);
}
const p = this.addonProviders[config.provider];
if (!p) {
throw new TypeError(`Unknown addon provider ${config.provider}`);
throw new ValidationError(
`Unknown addon provider ${config.provider}`,
[],
undefined,
);
} else {
return true;
}

View File

@ -2,7 +2,7 @@ import { Store } from './store';
export interface IAddonDto {
provider: string;
description: string;
description?: string | null;
enabled: boolean;
parameters: Record<string, unknown>;
projects?: string[];
@ -13,6 +13,7 @@ export interface IAddonDto {
export interface IAddon extends IAddonDto {
id: number;
createdAt: Date;
description: string | null;
}
export interface IAddonStore extends Store<IAddon, number> {

View File

@ -1,5 +1,5 @@
import dbInit from '../../helpers/database-init';
import { setupApp } from '../../helpers/test-helper';
import { setupAppWithCustomConfig } from '../../helpers/test-helper';
import getLogger from '../../../fixtures/no-logger';
const MASKED_VALUE = '*****';
@ -9,7 +9,13 @@ let db;
beforeAll(async () => {
db = await dbInit('addon_api_serial', getLogger);
app = await setupApp(db.stores);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
},
},
});
});
afterAll(async () => {
@ -196,3 +202,98 @@ test('should not delete unknown addon configuration', async () => {
return app.request.delete('/api/admin/addons/21231').expect(404);
});
test("should return 400 if it doesn't recognize the provider", async () => {
const payload = {
provider: 'htni',
enabled: true,
parameters: {},
events: [],
};
return app.request.post('/api/admin/addons').send(payload).expect(400);
});
test('updating an addon returns the new addon configuration', async () => {
const config = {
provider: 'webhook',
enabled: true,
parameters: {
url: 'http://localhost:4242/webhook',
},
events: [],
};
const { body } = await app.request.post('/api/admin/addons').send(config);
const updatedConfig = {
...config,
enabled: false,
parameters: { url: 'http://new-url:4343' },
};
return app.request
.put(`/api/admin/addons/${body.id}`)
.send(updatedConfig)
.expect((res) => {
expect(res.body).toMatchObject(updatedConfig);
});
});
describe('missing descriptions', () => {
const addonWithoutDescription = {
provider: 'webhook',
enabled: true,
parameters: {
url: 'http://localhost:4242/webhook',
},
events: ['feature-created', 'feature-updated'],
};
test('creating an addon without a description, sets the description to `null`', async () => {
const { body } = await app.request
.post('/api/admin/addons')
.send(addonWithoutDescription)
.expect((res) => {
expect(res.body.description).toBeNull();
});
return app.request
.get(`/api/admin/addons/${body.id}`)
.expect((getResponse) => {
expect(getResponse.body.description).toBeNull();
});
});
test('updating an addon without touching `description` keeps the original value', async () => {
const { body } = await app.request
.post('/api/admin/addons')
.send(addonWithoutDescription);
const newUrl = 'http://localhost:4242/newUrl';
return app.request
.put(`/api/admin/addons/${body.id}`)
.send({ ...addonWithoutDescription, parameters: { url: newUrl } })
.expect((res) => {
expect(res.body.description).toBeNull();
});
});
test.each(['', null])(
'sending a description value of "%s", sets a `null` sets the description to an empty string',
async (description) => {
const { body } = await app.request
.post('/api/admin/addons')
.send(addonWithoutDescription);
return app.request
.put(`/api/admin/addons/${body.id}`)
.send({
...addonWithoutDescription,
description,
})
.expect((res) => {
expect(res.body.description).toStrictEqual('');
});
},
);
});

View File

@ -44,27 +44,121 @@ exports[`should serve the OpenAPI spec 1`] = `
{
"components": {
"schemas": {
"addonParameterSchema": {
"addonCreateUpdateSchema": {
"description": "Data required to create or update an [Unleash addon](https://docs.getunleash.io/reference/addons) instance.",
"properties": {
"description": {
"description": "A description of the addon.",
"example": "This addon posts updates to our internal feature tracking system whenever a feature is created or updated.",
"type": "string",
},
"enabled": {
"description": "Whether the addon should be enabled or not.",
"type": "boolean",
},
"environments": {
"description": "The list of environments that this addon will listen to events from. An empty list means it will listen to events from **all** environments.",
"example": [
"development",
"production",
],
"items": {
"type": "string",
},
"type": "array",
},
"events": {
"description": "The event types that will trigger this specific addon.",
"example": [
"feature-created",
"feature-updated",
],
"items": {
"type": "string",
},
"type": "array",
},
"parameters": {
"additionalProperties": {},
"description": "Parameters for the addon provider. This object has different required and optional properties depending on the provider you choose. Consult the documentation for details.",
"example": {
"url": "http://localhost:4242/webhook",
},
"type": "object",
},
"projects": {
"description": "The projects that this addon will listen to events from. An empty list means it will listen to events from **all** projects.",
"example": [
"new-landing-project",
"signups-v2",
],
"items": {
"type": "string",
},
"type": "array",
},
"provider": {
"description": "The addon provider, such as "webhook" or "slack". This string is **case sensitive** and maps to the provider's \`name\` property.
The list of all supported providers and their parameters for a specific Unleash instance can be found by making a GET request to the \`api/admin/addons\` endpoint: the \`providers\` property of that response will contain all available providers.
The default set of providers can be found in the [addons reference documentation](https://docs.getunleash.io/reference/addons). The default supported options are:
- \`datadog\` for [Datadog](https://docs.getunleash.io/reference/addons/datadog)
- \`slack\` for [Slack](https://docs.getunleash.io/reference/addons/slack)
- \`teams\` for [Microsoft Teams](https://docs.getunleash.io/reference/addons/teams)
- \`webhook\` for [webhooks](https://docs.getunleash.io/reference/addons/webhook)
The provider you choose for your addon dictates what properties the \`parameters\` object needs. Refer to the documentation for each provider for more information.
",
"example": "webhook",
"type": "string",
},
},
"required": [
"provider",
"enabled",
"parameters",
"events",
],
"type": "object",
},
"addonParameterSchema": {
"additionalProperties": false,
"description": "An addon parameter definition.",
"properties": {
"description": {
"description": "A description of the parameter. This should explain to the end user what the parameter is used for.",
"example": "The emoji_icon to use when posting messages to slack. Defaults to ":unleash:".",
"type": "string",
},
"displayName": {
"description": "The name of the parameter as it is shown to the end user in the Admin UI.",
"example": "Emoji Icon",
"type": "string",
},
"name": {
"description": "The name of the parameter as it is used in code. References to this parameter should use this value.",
"example": "emojiIcon",
"type": "string",
},
"placeholder": {
"description": "The default value for this parameter. This value is used if no other value is provided.",
"example": ":unleash:",
"type": "string",
},
"required": {
"description": "Whether this parameter is required or not. If a parameter is required, you must give it a value when you create the addon. If it is not required it can be left out. It may receive a default value in those cases.",
"example": false,
"type": "boolean",
},
"sensitive": {
"description": "Indicates whether this parameter is **sensitive** or not. Unleash will not return sensitive parameters to API requests. It will instead use a number of asterisks to indicate that a value is set, e.g. "******". The number of asterisks does not correlate to the parameter's value.",
"example": false,
"type": "boolean",
},
"type": {
"description": "The type of the parameter. Corresponds roughly to [HTML \`input\` field types](https://developer.mozilla.org/docs/Web/HTML/Element/Input#input_types). Multi-line inut fields are indicated as \`textfield\` (equivalent to the HTML \`textarea\` tag).",
"example": "text",
"type": "string",
},
},
@ -78,48 +172,74 @@ exports[`should serve the OpenAPI spec 1`] = `
"type": "object",
},
"addonSchema": {
"description": "An [addon](https://docs.getunleash.io/reference/addons) instance description. Contains data about what kind of provider it uses, whether it's enabled or not, what events it listens for, and more.",
"properties": {
"createdAt": {
"format": "date-time",
"description": {
"description": "A description of the addon. \`null\` if no description exists.",
"example": "This addon posts updates to our internal feature tracking system whenever a feature is created or updated.",
"nullable": true,
"type": "string",
},
"description": {
"type": "string",
},
"enabled": {
"description": "Whether the addon is enabled or not.",
"type": "boolean",
},
"environments": {
"description": "The list of environments that this addon listens to events from. An empty list means it listens to events from **all** environments.",
"example": [
"development",
"production",
],
"items": {
"type": "string",
},
"type": "array",
},
"events": {
"description": "The event types that trigger this specific addon.",
"example": [
"feature-created",
"feature-updated",
],
"items": {
"type": "string",
},
"type": "array",
},
"id": {
"type": "number",
"description": "The addon's unique identifier.",
"example": 27,
"minimum": 1,
"type": "integer",
},
"parameters": {
"additionalProperties": true,
"additionalProperties": {},
"description": "Parameters for the addon provider. This object has different required and optional properties depending on the provider you choose.",
"example": {
"url": "http://localhost:4242/webhook",
},
"type": "object",
},
"projects": {
"description": "The projects that this addon listens to events from. An empty list means it listens to events from **all** projects.",
"example": [
"new-landing-project",
"signups-v2",
],
"items": {
"type": "string",
},
"type": "array",
},
"provider": {
"description": "The addon provider, such as "webhook" or "slack".",
"example": "webhook",
"type": "string",
},
},
"required": [
"id",
"description",
"provider",
"enabled",
"parameters",
@ -128,32 +248,104 @@ exports[`should serve the OpenAPI spec 1`] = `
"type": "object",
},
"addonTypeSchema": {
"additionalProperties": false,
"description": "An addon provider. Defines a specific addon type and what the end user must configure when creating a new addon of that type.",
"properties": {
"description": {
"description": "A description of the addon type.",
"example": "Allows Unleash to post updates to Slack.",
"type": "string",
},
"displayName": {
"description": "The addon type's name as it should be displayed in the admin UI.",
"example": "Slack",
"type": "string",
},
"documentationUrl": {
"description": "A URL to where you can find more information about using this addon type.",
"example": "https://docs.getunleash.io/docs/addons/slack",
"type": "string",
},
"events": {
"description": "All the [event types](https://docs.getunleash.io/reference/api/legacy/unleash/admin/events#feature-toggle-events) that are available for this addon provider.",
"example": [
"feature-created",
"feature-updated",
"feature-archived",
"feature-revived",
"feature-stale-on",
"feature-stale-off",
"feature-environment-enabled",
"feature-environment-disabled",
"feature-strategy-remove",
"feature-strategy-update",
"feature-strategy-add",
"feature-metadata-updated",
"feature-variants-updated",
"feature-project-change",
],
"items": {
"type": "string",
},
"type": "array",
},
"name": {
"description": "The name of the addon type. When creating new addons, this goes in the payload's \`type\` field.",
"example": "slack",
"type": "string",
},
"parameters": {
"description": "The addon provider's parameters. Use these to configure an addon of this provider type. Items with \`required: true\` must be provided.",
"example": [
{
"description": "(Required)",
"displayName": "Slack webhook URL",
"name": "url",
"required": true,
"sensitive": true,
"type": "url",
},
{
"description": "The username to use when posting messages to slack. Defaults to "Unleash".",
"displayName": "Username",
"name": "username",
"placeholder": "Unleash",
"required": false,
"sensitive": false,
"type": "text",
},
{
"description": "The emoji_icon to use when posting messages to slack. Defaults to ":unleash:".",
"displayName": "Emoji Icon",
"name": "emojiIcon",
"placeholder": ":unleash:",
"required": false,
"sensitive": false,
"type": "text",
},
{
"description": "(Required) Default channel to post updates to if not specified in the slack-tag",
"displayName": "Default channel",
"name": "defaultChannel",
"required": true,
"sensitive": false,
"type": "text",
},
],
"items": {
"$ref": "#/components/schemas/addonParameterSchema",
},
"type": "array",
},
"tagTypes": {
"description": "A list of [Unleash tag types](https://docs.getunleash.io/reference/tags#tag-types) that this addon uses. These tags will be added to the Unleash instance when an addon of this type is created.",
"example": [
{
"description": "Slack tag used by the slack-addon to specify the slack channel.",
"icon": "S",
"name": "slack",
},
],
"items": {
"$ref": "#/components/schemas/tagTypeSchema",
},
@ -169,14 +361,241 @@ exports[`should serve the OpenAPI spec 1`] = `
"type": "object",
},
"addonsSchema": {
"description": "An object containing two things:
1. A list of all [addons](https://docs.getunleash.io/reference/addons) defined on this Unleash instance
2. A list of all addon providers defined on this instance",
"properties": {
"addons": {
"description": "All the addons that exist on this instance of Unleash.",
"items": {
"$ref": "#/components/schemas/addonSchema",
},
"type": "array",
},
"providers": {
"description": "A list of all available addon providers, along with their parameters and descriptions.",
"example": [
{
"description": "A Webhook is a generic way to post messages from Unleash to third party services.",
"displayName": "Webhook",
"documentationUrl": "https://docs.getunleash.io/docs/addons/webhook",
"events": [
"feature-created",
"feature-updated",
"feature-archived",
"feature-revived",
"feature-stale-on",
"feature-stale-off",
"feature-environment-enabled",
"feature-environment-disabled",
"feature-strategy-remove",
"feature-strategy-update",
"feature-strategy-add",
"feature-metadata-updated",
"feature-variants-updated",
"feature-project-change",
"feature-tagged",
"feature-untagged",
"change-request-created",
"change-request-discarded",
"change-added",
"change-discarded",
"change-request-approved",
"change-request-approval-added",
"change-request-cancelled",
"change-request-sent-to-review",
"change-request-applied",
],
"name": "webhook",
"parameters": [
{
"description": "(Required) Unleash will perform a HTTP Post to the specified URL (one retry if first attempt fails)",
"displayName": "Webhook URL",
"name": "url",
"required": true,
"sensitive": true,
"type": "url",
},
{
"description": "(Optional) The Content-Type header to use. Defaults to "application/json".",
"displayName": "Content-Type",
"name": "contentType",
"placeholder": "application/json",
"required": false,
"sensitive": false,
"type": "text",
},
{
"description": "(Optional) The Authorization header to use. Not used if left blank.",
"displayName": "Authorization",
"name": "authorization",
"placeholder": "",
"required": false,
"sensitive": true,
"type": "text",
},
{
"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://docs.getunleash.io/reference/api/legacy/unleash/admin/events)",
"displayName": "Body template",
"name": "bodyTemplate",
"placeholder": "{
"event": "{{event.type}}",
"createdBy": "{{event.createdBy}}",
"featureToggle": "{{event.data.name}}",
"timestamp": "{{event.data.createdAt}}"
}",
"required": false,
"sensitive": false,
"type": "textfield",
},
],
},
{
"description": "Allows Unleash to post updates to Slack.",
"displayName": "Slack",
"documentationUrl": "https://docs.getunleash.io/docs/addons/slack",
"events": [
"feature-created",
"feature-updated",
"feature-archived",
"feature-revived",
"feature-stale-on",
"feature-stale-off",
"feature-environment-enabled",
"feature-environment-disabled",
"feature-strategy-remove",
"feature-strategy-update",
"feature-strategy-add",
"feature-metadata-updated",
"feature-variants-updated",
"feature-project-change",
],
"name": "slack",
"parameters": [
{
"description": "(Required)",
"displayName": "Slack webhook URL",
"name": "url",
"required": true,
"sensitive": true,
"type": "url",
},
{
"description": "The username to use when posting messages to slack. Defaults to "Unleash".",
"displayName": "Username",
"name": "username",
"placeholder": "Unleash",
"required": false,
"sensitive": false,
"type": "text",
},
{
"description": "The emoji_icon to use when posting messages to slack. Defaults to ":unleash:".",
"displayName": "Emoji Icon",
"name": "emojiIcon",
"placeholder": ":unleash:",
"required": false,
"sensitive": false,
"type": "text",
},
{
"description": "(Required) Default channel to post updates to if not specified in the slack-tag",
"displayName": "Default channel",
"name": "defaultChannel",
"required": true,
"sensitive": false,
"type": "text",
},
],
"tagTypes": [
{
"description": "Slack tag used by the slack-addon to specify the slack channel.",
"icon": "S",
"name": "slack",
},
],
},
{
"description": "Allows Unleash to post updates to Microsoft Teams.",
"displayName": "Microsoft Teams",
"documentationUrl": "https://docs.getunleash.io/docs/addons/teams",
"events": [
"feature-created",
"feature-updated",
"feature-archived",
"feature-revived",
"feature-stale-on",
"feature-stale-off",
"feature-environment-enabled",
"feature-environment-disabled",
"feature-strategy-remove",
"feature-strategy-update",
"feature-strategy-add",
"feature-metadata-updated",
"feature-variants-updated",
"feature-project-change",
],
"name": "teams",
"parameters": [
{
"description": "(Required)",
"displayName": "Microsoft Teams webhook URL",
"name": "url",
"required": true,
"sensitive": true,
"type": "url",
},
],
},
{
"description": "Allows Unleash to post updates to Datadog.",
"displayName": "Datadog",
"documentationUrl": "https://docs.getunleash.io/docs/addons/datadog",
"events": [
"feature-created",
"feature-updated",
"feature-archived",
"feature-revived",
"feature-stale-on",
"feature-stale-off",
"feature-environment-enabled",
"feature-environment-disabled",
"feature-strategy-remove",
"feature-strategy-update",
"feature-strategy-add",
"feature-metadata-updated",
"feature-project-change",
"feature-variants-updated",
],
"name": "datadog",
"parameters": [
{
"description": "Default url: https://api.datadoghq.com/api/v1/events. Needs to be changed if your not using the US1 site.",
"displayName": "Datadog Events URL",
"name": "url",
"required": false,
"sensitive": false,
"type": "url",
},
{
"description": "(Required) API key from Datadog",
"displayName": "Datadog API key",
"name": "apiKey",
"placeholder": "j96c23b0f12a6b3434a8d710110bd862",
"required": true,
"sensitive": true,
"type": "text",
},
],
"tagTypes": [
{
"description": "All Datadog tags added to a specific feature are sent to datadog event stream.",
"icon": "D",
"name": "datadog",
},
],
},
],
"items": {
"$ref": "#/components/schemas/addonTypeSchema",
},
@ -4619,6 +5038,7 @@ Stats are divided into current and previous **windows**.
"paths": {
"/api/admin/addons": {
"get": {
"description": "Retrieve all addons and providers that are defined on this Unleash instance.",
"operationId": "getAddons",
"responses": {
"200": {
@ -4631,22 +5051,27 @@ Stats are divided into current and previous **windows**.
},
"description": "addonsSchema",
},
"401": {
"description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.",
},
},
"summary": "Get all addons and providers",
"tags": [
"Addons",
],
},
"post": {
"description": "Create an addon instance. The addon must use one of the providers available on this Unleash instance.",
"operationId": "createAddon",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/addonSchema",
"$ref": "#/components/schemas/addonCreateUpdateSchema",
},
},
},
"description": "addonSchema",
"description": "addonCreateUpdateSchema",
"required": true,
},
"responses": {
@ -4660,7 +5085,23 @@ Stats are divided into current and previous **windows**.
},
"description": "addonSchema",
},
"400": {
"description": "The request data does not match what we expect.",
},
"401": {
"description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.",
},
"403": {
"description": "User credentials are valid but does not have enough privileges to execute this operation",
},
"413": {
"description": "The body request body is larger than what we accept. By default we only accept bodies of 100kB or less",
},
"415": {
"description": "The operation does not support request payloads of the provided type. Please ensure that you're using one of the listed payload types and that you have specified the right content type in the "content-type" header.",
},
},
"summary": "Create a new addon",
"tags": [
"Addons",
],
@ -4668,6 +5109,7 @@ Stats are divided into current and previous **windows**.
},
"/api/admin/addons/{id}": {
"delete": {
"description": "Delete the addon specified by the ID in the request path.",
"operationId": "deleteAddon",
"parameters": [
{
@ -4683,12 +5125,20 @@ Stats are divided into current and previous **windows**.
"200": {
"description": "This response has no body.",
},
"401": {
"description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.",
},
"403": {
"description": "User credentials are valid but does not have enough privileges to execute this operation",
},
},
"summary": "Delete an addon",
"tags": [
"Addons",
],
},
"get": {
"description": "Retrieve information about the addon whose ID matches the ID in the request URL.",
"operationId": "getAddon",
"parameters": [
{
@ -4711,12 +5161,19 @@ Stats are divided into current and previous **windows**.
},
"description": "addonSchema",
},
"401": {
"description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.",
},
},
"summary": "Get a specific addon",
"tags": [
"Addons",
],
},
"put": {
"description": "Update the addon with a specific ID. Any fields in the update object will be updated. Properties that are not included in the update object will not be affected. To empty a property, pass \`null\` as that property's value.
Note: passing \`null\` as a value for the description property will set it to an empty string.",
"operationId": "updateAddon",
"parameters": [
{
@ -4732,11 +5189,11 @@ Stats are divided into current and previous **windows**.
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/addonSchema",
"$ref": "#/components/schemas/addonCreateUpdateSchema",
},
},
},
"description": "addonSchema",
"description": "addonCreateUpdateSchema",
"required": true,
},
"responses": {
@ -4750,7 +5207,23 @@ Stats are divided into current and previous **windows**.
},
"description": "addonSchema",
},
"400": {
"description": "The request data does not match what we expect.",
},
"401": {
"description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.",
},
"403": {
"description": "User credentials are valid but does not have enough privileges to execute this operation",
},
"413": {
"description": "The body request body is larger than what we accept. By default we only accept bodies of 100kB or less",
},
"415": {
"description": "The operation does not support request payloads of the provided type. Please ensure that you're using one of the listed payload types and that you have specified the right content type in the "content-type" header.",
},
},
"summary": "Update an addon",
"tags": [
"Addons",
],

View File

@ -43,6 +43,7 @@ export default class FakeAddonStore implements IAddonStore {
const ins: IAddon = {
id: this.highestId++,
createdAt: new Date(),
description: null,
...addon,
};
this.addons.push(ins);
@ -51,7 +52,12 @@ export default class FakeAddonStore implements IAddonStore {
async update(id: number, addon: IAddonDto): Promise<IAddon> {
await this.delete(id);
const inserted: IAddon = { id, createdAt: new Date(), ...addon };
const inserted: IAddon = {
id,
createdAt: new Date(),
description: null,
...addon,
};
this.addons.push(inserted);
return inserted;
}