diff --git a/docs/node/webhook.md b/docs/node/webhook.md index df4d203fbe..4fa916950f 100644 --- a/docs/node/webhook.md +++ b/docs/node/webhook.md @@ -9,23 +9,17 @@ in Home Assistant for this node to function_ ## Configuration -### ID +### ID - Type: `string` A string to be used for the webhook URL in Home Assistant. -### Payload +### Allowed Methods -- Type: `string` - -Customizable location for the webhook payload. Defaults to msg.payload - -### Headers - -- Type: `number` +- Type: `list` -Customizable location for the webhook request headers. +A list of allowed methods that Home Assistant will accept for the webhook. At least one method must be selected. ## Outputs diff --git a/locales/en-US/common.json b/locales/en-US/common.json index da69c2601c..957d023a03 100644 --- a/locales/en-US/common.json +++ b/locales/en-US/common.json @@ -67,6 +67,7 @@ "off": "off", "on": "on", "pressed": "pressed", + "received": "received", "registered": "registered", "running": "running", "sending": "sending", diff --git a/locales/en-US/webhook.json b/locales/en-US/webhook.json new file mode 100644 index 0000000000..d304d6e45e --- /dev/null +++ b/locales/en-US/webhook.json @@ -0,0 +1,11 @@ +{ + "ha-webhook": { + "label": { + "allowed_methods": "Allowed Methods", + "put": "PUT", + "post": "POST", + "get": "GET", + "head": "HEAD" + } + } +} diff --git a/src/common/integration/Integration.ts b/src/common/integration/Integration.ts index bd4113a534..e41846d37a 100644 --- a/src/common/integration/Integration.ts +++ b/src/common/integration/Integration.ts @@ -22,8 +22,9 @@ export enum MessageType { Discovery = 'nodered/discovery', Entity = 'nodered/entity', RemoveDevice = 'nodered/device/remove', - UpdateConfig = 'nodered/entity/update_config', SentenseTrigger = 'nodered/sentence', + UpdateConfig = 'nodered/entity/update_config', + Webhook = 'nodered/webhook', } export interface MessageBase { diff --git a/src/nodes/webhook/WebhookController.ts b/src/nodes/webhook/WebhookController.ts new file mode 100644 index 0000000000..b42038674b --- /dev/null +++ b/src/nodes/webhook/WebhookController.ts @@ -0,0 +1,44 @@ +import OutputController, { + OutputControllerOptions, +} from '../../common/controllers/OutputController'; +import { IntegrationEvent } from '../../common/integration/Integration'; +import { NodeMessage } from '../../types/nodes'; +import { WebhookNode } from '.'; + +interface WebhookResponse { + payload: any; + headers: Record; + params: Record; +} + +type WebhookNodeOptions = OutputControllerOptions; + +export default class WebhookController extends OutputController { + constructor(props: WebhookNodeOptions) { + super(props); + + this.node.addListener( + IntegrationEvent.Trigger, + this.#onReceivedMessage.bind(this) + ); + } + + #onReceivedMessage(data: WebhookResponse) { + const message: NodeMessage = {}; + try { + this.setCustomOutputs(this.node.config.outputProperties, message, { + config: this.node.config, + data: data.payload, + headers: data.headers, + params: data.params, + }); + } catch (e) { + this.node.error(e); + this.status.setFailed('error'); + return; + } + + this.status.setSuccess('home-assistant.status.received'); + this.node.send(message); + } +} diff --git a/src/nodes/webhook/WebhookIntegration.ts b/src/nodes/webhook/WebhookIntegration.ts new file mode 100644 index 0000000000..607d741bef --- /dev/null +++ b/src/nodes/webhook/WebhookIntegration.ts @@ -0,0 +1,42 @@ +import BidirectionalIntegration, { + DiscoveryBaseMessage, +} from '../../common/integration/BidrectionalIntegration'; +import { MessageType } from '../../common/integration/Integration'; +import { WebhookNodeProperties } from '.'; + +export interface WebhookDiscoveryPayload extends DiscoveryBaseMessage { + type: MessageType.Webhook; + server_id: string; + webhook_id: string; + name: string; + allowed_methods: string[]; +} + +export default class WebhookIntegration extends BidirectionalIntegration { + protected getDiscoveryPayload( + config: WebhookNodeProperties + ): WebhookDiscoveryPayload { + const methods = [ + 'method_post', + 'method_get', + 'method_put', + 'method_head', + ] as const; + + const allowedMethods = methods.reduce((acc, method) => { + if (config[method]) { + acc.push(method.replace('method_', '').toUpperCase()); + } + return acc; + }, [] as string[]); + + return { + type: MessageType.Webhook, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + server_id: config.server!, + webhook_id: config.webhookId, + name: config.id, + allowed_methods: allowedMethods, + }; + } +} diff --git a/src/nodes/webhook/controller.js b/src/nodes/webhook/controller.js deleted file mode 100644 index 51b4877873..0000000000 --- a/src/nodes/webhook/controller.js +++ /dev/null @@ -1,102 +0,0 @@ -const EventsNode = require('../EventsNode'); -const { INTEGRATION_UNLOADED } = require('../../const'); - -const nodeOptions = { - config: { - name: {}, - server: { isNode: true }, - outputs: 1, - webhookId: {}, - exposeToHomeAssistant: () => true, - outputProperties: {}, - }, -}; - -class Webhook extends EventsNode { - constructor({ node, config, RED, status }) { - super({ node, config, RED, status, nodeOptions }); - - if (this.isIntegrationLoaded) { - this.registerEntity(); - } - } - - onHaEventsClose() { - super.onHaEventsClose(); - - this.removeWebhook = null; - } - - onEvent(evt) { - const message = {}; - try { - this.setCustomOutputs(this.nodeConfig.outputProperties, message, { - config: this.nodeConfig, - data: evt.data.payload, - headers: evt.data.headers, - params: evt.data.params, - }); - } catch (e) { - this.node.error(e); - this.status.setFailed('error'); - return; - } - - this.status.setSuccess('Received'); - this.send(message); - } - - onHaIntegration(type) { - super.onHaIntegration(type); - - if (type === INTEGRATION_UNLOADED) { - if (this.removeWebhook) { - this.removeWebhook(); - this.removeWebhook = null; - } - this.node.error( - 'Node-RED custom integration has been removed from Home Assistant it is needed for this node to function.' - ); - this.status.setFailed('Error'); - } - } - - async registerEntity() { - if (super.registerEntity() === false) { - return; - } - - if (!this.nodeConfig.webhookId) { - this.node.error(this.integrationErrorMessage); - this.status.setFailed('Error'); - return; - } - - if (!this.removeWebhook) { - this.node.debug(`Adding webhook to HA`); - this.removeWebhook = await this.homeAssistant.subscribeMessage( - this.onEvent.bind(this), - { - type: 'nodered/webhook', - webhook_id: this.nodeConfig.webhookId, - name: this.node.id, - server_id: this.nodeConfig.server.id, - }, - { resubscribe: false } - ); - } - this.status.setSuccess('Registered'); - this.registered = true; - } - - onClose(removed) { - super.onClose(removed); - - if (this.registered && this.isConnected && this.removeWebhook) { - this.node.debug('Removing webhook from HA'); - this.removeWebhook().catch(() => {}); - } - } -} - -module.exports = Webhook; diff --git a/src/nodes/webhook/editor.html b/src/nodes/webhook/editor.html index ba8a2e7d97..e74f5187e4 100644 --- a/src/nodes/webhook/editor.html +++ b/src/nodes/webhook/editor.html @@ -24,4 +24,36 @@ +
+

+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+
    diff --git a/src/nodes/webhook/editor.ts b/src/nodes/webhook/editor.ts index 5b42642af2..11a34a52cb 100644 --- a/src/nodes/webhook/editor.ts +++ b/src/nodes/webhook/editor.ts @@ -13,6 +13,10 @@ interface WebhookEditorNodeProperties extends EditorNodeProperties { server: string; version: number; webhookId: string; + method_get: boolean; + method_head: boolean; + method_post: boolean; + method_put: boolean; outputProperties: OutputProperty[]; // deprecated but needed for imports @@ -32,6 +36,12 @@ function generateId(length: number) { ).join(''); } +// Check that at least one method is enabled +function validateMethods(): boolean { + const methods = ['method_post', 'method_get', 'method_put', 'method_head']; + return methods.some((method) => this[method]); +} + const WebhookEditor: EditorNodeDef = { category: NodeCategory.HomeAssistant, color: NodeColor.HaBlue, @@ -49,6 +59,10 @@ const WebhookEditor: EditorNodeDef = { version: { value: RED.settings.get('haWebhookVersion', 0) }, outputs: { value: 1 }, webhookId: { value: generateId(32), required: true }, + method_get: { value: false, validate: validateMethods }, + method_head: { value: false, validate: validateMethods }, + method_post: { value: true, validate: validateMethods }, + method_put: { value: true, validate: validateMethods }, outputProperties: { value: [ { @@ -95,6 +109,19 @@ const WebhookEditor: EditorNodeDef = { $webhookId.val(generateId(32)); }); + $('[id^=node-input-method_]') + .on('change', function () { + const isMethodSelected = $('[id^=node-input-method_]').is( + ':checked' + ); + if (!isMethodSelected) { + $(this).closest('div').addClass('input-error'); + } else { + $(this).closest('div').removeClass('input-error'); + } + }) + .trigger('change'); + haOutputs.createOutputs(this.outputProperties, { extraTypes: ['receivedData', 'headers', 'params', 'triggerId'], }); diff --git a/src/nodes/webhook/index.ts b/src/nodes/webhook/index.ts index cca7d1691d..7354925dc6 100644 --- a/src/nodes/webhook/index.ts +++ b/src/nodes/webhook/index.ts @@ -1,22 +1,71 @@ -import { NodeDef } from 'node-red'; - +import { createControllerDependencies } from '../../common/controllers/helpers'; +import ClientEvents from '../../common/events/ClientEvents'; +import Events from '../../common/events/Events'; +import State from '../../common/State'; +import Status from '../../common/status/Status'; import { RED } from '../../globals'; import { migrate } from '../../helpers/migrate'; -import { EventsStatus } from '../../helpers/status'; -import { checkValidServerConfig } from '../../helpers/utils'; -import { BaseNode } from '../../types/nodes'; -import Webhook from './controller'; +import { getServerConfigNode } from '../../helpers/node'; +import { getHomeAssistant } from '../../homeAssistant'; +import { + BaseNode, + BaseNodeProperties, + OutputProperty, +} from '../../types/nodes'; +import WebhookController from './WebhookController'; +import WebhookIntegration from './WebhookIntegration'; -export default function webhookNode(this: BaseNode, config: NodeDef) { - RED.nodes.createNode(this, config); +export interface WebhookNodeProperties extends BaseNodeProperties { + webhookId: string; + method_get: boolean; + method_head: boolean; + method_put: boolean; + method_post: boolean; + local_only: boolean; + outputProperties: OutputProperty[]; +} + +export interface WebhookNode extends BaseNode { + config: WebhookNodeProperties; +} +export default function webhookNode( + this: WebhookNode, + config: WebhookNodeProperties +) { + RED.nodes.createNode(this, config); this.config = migrate(config); - checkValidServerConfig(this, this.config.server); - const status = new EventsStatus(this); - this.controller = new Webhook({ + + const serverConfigNode = getServerConfigNode(this.config.server); + const homeAssistant = getHomeAssistant(serverConfigNode); + const clientEvents = new ClientEvents({ + node: this, + emitter: homeAssistant.eventBus, + }); + const nodeEvents = new Events({ node: this, emitter: this }); + const state = new State(this); + const status = new Status({ + config: serverConfigNode.config, + nodeEvents, + node: this, + state, + }); + + const controllerDeps = createControllerDependencies(this, homeAssistant); + const integration = new WebhookIntegration({ + node: this, + clientEvents, + homeAssistant, + state, + }); + integration.setStatus(status); + + this.controller = new WebhookController({ node: this, - config: this.config, - RED, status, + ...controllerDeps, + state, }); + + integration.init(); } diff --git a/src/nodes/webhook/migrations.ts b/src/nodes/webhook/migrations.ts index 370a494f62..60108eb42f 100644 --- a/src/nodes/webhook/migrations.ts +++ b/src/nodes/webhook/migrations.ts @@ -48,4 +48,19 @@ export default [ return newSchema; }, }, + { + version: 2, + up: (schema: any) => { + const newSchema = { + ...schema, + version: 2, + method_post: true, + method_put: true, + method_get: false, + method_head: false, + }; + + return newSchema; + }, + }, ]; diff --git a/test/migrations/webhook.test.js b/test/migrations/webhook.test.js index 3ef9881b7c..07a0eb1373 100644 --- a/test/migrations/webhook.test.js +++ b/test/migrations/webhook.test.js @@ -47,6 +47,14 @@ const VERSION_1 = { headersLocation: undefined, headersLocationType: undefined, }; +const VERSION_2 = { + ...VERSION_1, + version: 2, + method_post: true, + method_put: true, + method_get: false, + method_head: false, +}; describe('Migrations - Webhook Node', function () { describe('Version 0', function () { @@ -63,8 +71,15 @@ describe('Migrations - Webhook Node', function () { expect(migratedSchema).to.eql(VERSION_1); }); }); + describe('Version 2', function () { + it('should update version 1 to version 2', function () { + const migrate = migrations.find((m) => m.version === 2); + const migratedSchema = migrate.up(VERSION_1); + expect(migratedSchema).to.eql(VERSION_2); + }); + }); it('should update an undefined version to current version', function () { const migratedSchema = migrate(VERSION_UNDEFINED); - expect(migratedSchema).to.eql(VERSION_1); + expect(migratedSchema).to.eql(VERSION_2); }); });