From 0731a98e971affd42a1a7e6bc0c6df48f14cf12b Mon Sep 17 00:00:00 2001 From: uboness Date: Tue, 18 Aug 2015 13:13:42 +0200 Subject: [PATCH] Introducing HipChat Action An action capable of sending notifications to rooms and users on hipchat. This actions support three types of HipChat APIs: - `v1` - The (now deprecated) legacy API where a token can be registered at the group level, and the `v1` version of the API can be used. This API only supports room notification (users cannot be notified). multi-room notification is supported. - `integration` - The basic integration that one can create in HipChat (it is using the `v2` API version), where notifications can be sent to a single room. User notification is unsupported by this API - `user` - this API uses an API token of a specific user. An admin user can create an API token and configure it to have access to room notification and user private messaging. This API supports multi-room and multi-user notifications. The settings for `hipchat` are very similar to the `email` infrastructure in nature. It is possible to configure multiple/different hipchat account, each is associated with the api type (a.k.a profile) - can be `v1`, `integration` or `user`, and the respective `auth_token`. When configuring the action in the watch, one can specify what hipchat account they would like to use (when not specifying an account, the `default_account` will be used). Each account can also specify its own unique `host`/`port` for the hipchat server - for full flexibility. Closes elastic/elasticsearch#462 Original commit: elastic/x-pack-elasticsearch@9d9ee1354231a06c312e50e2de7c21e345486bb9 --- .../images/hipchat-integration-example.png | Bin 0 -> 150246 bytes watcher/docs/reference/actions.asciidoc | 10 +- .../docs/reference/actions/hipchat.asciidoc | 235 +++++++++ .../elasticsearch/watcher/WatcherPlugin.java | 12 +- .../elasticsearch/watcher/actions/Action.java | 2 +- .../watcher/actions/ActionBuilders.java | 24 + .../watcher/actions/WatcherActionModule.java | 28 +- .../email/service/InternalEmailService.java | 10 +- .../hipchat/ExecutableHipChatAction.java | 58 +++ .../actions/hipchat/HipChatAction.java | 257 +++++++++ .../actions/hipchat/HipChatActionFactory.java | 56 ++ .../hipchat/service/HipChatAccount.java | 120 +++++ .../hipchat/service/HipChatAccounts.java | 72 +++ .../hipchat/service/HipChatMessage.java | 489 ++++++++++++++++++ .../hipchat/service/HipChatServer.java | 59 +++ .../hipchat/service/HipChatService.java | 24 + .../hipchat/service/IntegrationAccount.java | 130 +++++ .../service/InternalHipChatService.java | 76 +++ .../actions/hipchat/service/SentMessages.java | 152 ++++++ .../actions/hipchat/service/UserAccount.java | 172 ++++++ .../actions/hipchat/service/V1Account.java | 130 +++++ .../watcher/support/http/HttpRequest.java | 51 +- .../watcher/support/http/HttpResponse.java | 23 + .../xcontent/WatcherXContentUtils.java | 28 +- watcher/src/main/resources/watch_history.json | 63 +++ .../org/elasticsearch/watcher/WatcherF.java | 16 + .../hipchat/HipChatActionFactoryTests.java | 209 ++++++++ .../actions/hipchat/HipChatActionTests.java | 262 ++++++++++ .../hipchat/service/HipChatAccountsTests.java | 129 +++++ .../hipchat/service/HipChatMessageTests.java | 291 +++++++++++ .../hipchat/service/HipChatServiceIT.java | 202 ++++++++ .../service/IntegrationAccountTests.java | 151 ++++++ .../service/InternalHipChatServiceTests.java | 281 ++++++++++ .../hipchat/service/UserAccountTests.java | 239 +++++++++ .../hipchat/service/V1AccountTests.java | 160 ++++++ .../actions/index/IndexActionTests.java | 1 - .../test/AbstractWatcherIntegrationTests.java | 1 + .../test/integration/BasicWatcherTests.java | 1 - 38 files changed, 4176 insertions(+), 48 deletions(-) create mode 100644 watcher/docs/images/hipchat-integration-example.png create mode 100644 watcher/docs/reference/actions/hipchat.asciidoc create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/ExecutableHipChatAction.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/HipChatAction.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionFactory.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccount.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccounts.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatMessage.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatServer.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatService.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/IntegrationAccount.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/InternalHipChatService.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/SentMessages.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/UserAccount.java create mode 100644 watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/V1Account.java create mode 100644 watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionFactoryTests.java create mode 100644 watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionTests.java create mode 100644 watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccountsTests.java create mode 100644 watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatMessageTests.java create mode 100644 watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatServiceIT.java create mode 100644 watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/IntegrationAccountTests.java create mode 100644 watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/InternalHipChatServiceTests.java create mode 100644 watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/UserAccountTests.java create mode 100644 watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/V1AccountTests.java diff --git a/watcher/docs/images/hipchat-integration-example.png b/watcher/docs/images/hipchat-integration-example.png new file mode 100644 index 0000000000000000000000000000000000000000..9d8bec6bdf947f275c7f5593324c9c0cb6d0b5f4 GIT binary patch literal 150246 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%0Aeh2zwmWdHON2cXO$jRLmp!FUa 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

  • HmcZf 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 Qhj_naB^4#A#f(D!52&~keE>, -<>, <> and -<>. +Watcher supports five action types: <>, +<>, <>, +<> and <> include::actions/email.asciidoc[] @@ -167,4 +167,6 @@ include::actions/webhook.asciidoc[] include::actions/index.asciidoc[] -include::actions/logging.asciidoc[] \ No newline at end of file +include::actions/logging.asciidoc[] + +include::actions/hipchat.asciidoc[] \ No newline at end of file diff --git a/watcher/docs/reference/actions/hipchat.asciidoc b/watcher/docs/reference/actions/hipchat.asciidoc new file mode 100644 index 00000000000..121401bbf56 --- /dev/null +++ b/watcher/docs/reference/actions/hipchat.asciidoc @@ -0,0 +1,235 @@ +[[actions-hipchat]] +==== HipChat Action + +A watch <> that can connect to a https://www.hipchat.com[HipChat] server and send +messages to users and rooms of a specific group. + + +[[configuring-hipchat-actions]] +===== Configuring HipChat Actions + +You configure hipchat actions in a watch's `actions` array. Action-specific attributes are +specified using the `hipchat` keyword. + +The following snippet shows a simple hipchat action definition: + +[source,json] +-------------------------------------------------- +"actions" : { + "notify-hipchat" : { <1> + "transform" : { ... }, <2> + "throttle_period" : "5m", <3> + "hipchat" : { + "to" : { + "room" : "server-status" <4> + }, + "message" : "Encountered {{ctx.payload.hits.total}} errors in the last 5 minutes (facepalm)" <5> + } + } +} +-------------------------------------------------- + +<1> The id of the action +<2> An optional <> to transform the payload before executing the `webhook` action +<3> An optional <> for the action (5 minutes in this example) +<4> The room where the message is sent to +<5> The content of the message. + +HipChat provides an extensive set of APIs via which Watcher sends messages to users and rooms. These APIs +are exposed via different integration mechanism. Watcher refers to these as *Profiles*, each can be identified +with its own unique name: `v1`, `integration` and `user`. + +Different profiles support different features and require different set of configuration (both on watcher +side and on the HipChat server side). + +Before using the `hipchat` action in a watch, Watcher's internal HipChat service needs to be configured. This +Service enable the configuration of multiple HipChat accounts. An HipChat Account defines the following: + +* `name` - (required) uniquely identifies the account. HipChat actions may specify the name of the account with which + the messages should be sent. +* `profile` - (required) the profile that is associated with this account, effectively defining what APIs this account uses. +* `auth_token` - (required) the authentication token that is used to execute the HipChat API in the account. +* `host` - (optional) defines the host of the HipChat server. When not defined it fall back on the default host (see bellow) +* `port` - (optional) defines the port of the HipChat server. When not defined it fall back on the default port (see bellow) +* `message_defaults` - (optional) a set of settings to define the default settings of the messages that are sent via this account +* `room` - Some account are bound to a single room (messages that are sent using their associated profiles can only be + sent to a specific room). For those account, this setting defines the room the account is bound to. + +Here's an example settings for HipChat service: + +[source,yaml] +-------------------------------------------------- +watcher.actions.hipchat: + default_account: v1 + account: + account1: + profile: v1 + auth_token: XXXXXXXXX + message_default: + color: yello + message_format: text + account2: + profile: integration + auth_token: YYYYYYYYY + room: mission-control + message_default: + color: red + message_format: text +-------------------------------------------------- + +[[hipchat-api-v1]] +===== `v1` Account + +WARNING: This account uses a deprecated API and is expected to be removed by HipChat in the future. + +The `v1` API was the first API HipChat ever exposed and therefore the most commonly used one. It is also the simplest +one to set up. To create the `v1` API token, please follow the instructions listed on https://www.hipchat.com/docs/api. + +NOTE: User private messages are not supported by this API. If private messages is what you are after, please + consider the <<`user`>> API instead. + +Once the an API is created, add the following settings in `elasticsearch.yml` file: + +The following table lists the available fields when setting up the `hipchat` action for the `v1` API: + +[[hipchat-api-v1-action-attributes]] +.v1 API Action Attributes +[options="header"] +|====== +| Name |Required | Default | Description +| `from` | no | the watch id | The name that will appear as the sender of the notification +| `room` | yes | - | The room/s that the notification should go to. Accepts a string value or an array of string values. +| `message` | yes | - | The content of the notification message (size is limited by HipChat to 1000 characters) +| `message_format` | no | html* | The format of the message. Possible options are: `text` or `html` +| `color` | no | yellow* | The background color of the notification in the room +| `notify` | no | false | Indicates whether people in the room should be actively notified +|====== + +Here is an example for how it looks like as part of the action definition: + +[source,json] +-------------------------------------------------- +"actions" : { + "notify-hipchat" : { + "transform" : { ... }, + "throttle_period" : "5m", + "hipchat" : { + "account" : "v1-account", + "message" : { + "from" : "watcher", + "room" : [ "server-status", "infra-team" ], + "message" : "Encountered {{ctx.payload.hits.total}} errors in the last 5 minutes (facepalm)", + "message_format" : "text", + "color" : "red", + "notify" : true + } + } + } +} +-------------------------------------------------- + + +[[hipchat-api-integration]] +===== `integration` Accounts + +This profiles uses HipChat https://www.hipchat.com/docs/apiv2/addons[Integrations]. More specifically, +it uses a built-in integration in HipChat that enables external systems to send notifications to a +specific room. To create the `integration` API token: + +* For HipChat.com, please follow the "Build your own integration" https://www.hipchat.com/docs/apiv2[instructions] +* For HipChat Server, please follow the "Build your own integration" https://confluence.atlassian.com/hc/administering-hipchat-server/integrations-with-hipchat-server[instructions] + +In both cases, the api token can be copied from the listed example (marked in red bellow) + +image:images/hipchat-integration-example.png[] + +NOTE: This API is the most limited APIs of the three as it only supports sending notifications to a single room and + does not support user private messages. If you are looking for multi-room notifications, please consider either + the <> or <> APIs. Only the latter supports user private + messages. + +When creating an account with the `integration` profile, you must configure the `room` setting as part +of the account setting. + +The following table lists the available fields when setting up the `hipchat` action an `integration` account: + +[[hipchat-api-integration-action-attributes]] +.v1 API Action Attributes +[options="header"] +|====== +| Name |Required | Default | Description +| `message` | yes | - | The content of the notification message (size is limited by HipChat to 1000 characters) +| `message_format` | no | html* | The format of the message. Possible options are: `text` or `html` +| `color` | no | yellow* | The background color of the notification in the room +| `notify` | no | false | Indicates whether people in the room should be actively notified +|====== + +Here is an example for how it looks like as part of the action definition: + +[source,json] +-------------------------------------------------- +"actions" : { + "notify-hipchat" : { + "transform" : { ... }, + "throttle_period" : "5m", + "hipchat" : { + "account" : "integration-account", + "message" : { + "message" : "Encountered {{ctx.payload.hits.total}} errors in the last 5 minutes (facepalm)", + "message_format" : "text", + "color" : "red", + "notify" : true + } + } + } +} +-------------------------------------------------- + +[[hipchat-api-user]] +===== `user` Accounts + +The `user` API is arguably the most flexible API. It is also safe to use as it and is based on HipChat's `v2` API version. +To use this API you will require to add a new HipChat user. With this the user in place, all messages sent via this +account will be sent on this user behalf (make sure you name the user appropriately). After creating the user, you need +to create an API token for it. To create a user token please follow the instructions on HipChat's online documentation. +//// +TODO: could not find a good link for that... we might need to show screenshots of the UI +//// + +While not supported by `v1` and `integration` accounts, the `user` account enables private user notification. + +The following table lists the available fields when setting up the `hipchat` action for the `user` API: + +[[hipchat-api-user-action-attributes]] +.v1 API Action Attributes +[options="header"] +|====== +| Name |Required | Default | Description +| `message` | yes | - | The content of the notification message (size is limited by HipChat to 1000 characters) +| `message_format` | no | html* | The format of the message. Possible options are: `text` or `html` +| `color` | no | yellow* | The background color of the notification in the room +| `notify` | no | false | Indicates whether people in the room should be actively notified +|====== + +Here is an example for how it looks like as part of the action definition: + +[source,json] +-------------------------------------------------- +"actions" : { + "notify-hipchat" : { + "transform" : { ... }, + "throttle_period" : "5m", + "hipchat" : { + "account" : "integration-account", + "message" : { + "room" : [ "mission-control", "devops" ], + "user" : "website-admin@example.com", + "message" : "Encountered {{ctx.payload.hits.total}} errors in the last 5 minutes (facepalm)", + "message_format" : "text", + "color" : "red", + "notify" : true + } + } + } +} +-------------------------------------------------- diff --git a/watcher/src/main/java/org/elasticsearch/watcher/WatcherPlugin.java b/watcher/src/main/java/org/elasticsearch/watcher/WatcherPlugin.java index 67b0a1abd1c..e6b0aa3fd5f 100644 --- a/watcher/src/main/java/org/elasticsearch/watcher/WatcherPlugin.java +++ b/watcher/src/main/java/org/elasticsearch/watcher/WatcherPlugin.java @@ -20,6 +20,7 @@ import org.elasticsearch.script.ScriptModule; import org.elasticsearch.shield.authz.AuthorizationModule; import org.elasticsearch.watcher.actions.WatcherActionModule; import org.elasticsearch.watcher.actions.email.service.InternalEmailService; +import org.elasticsearch.watcher.actions.hipchat.service.InternalHipChatService; import org.elasticsearch.watcher.client.WatcherClientModule; import org.elasticsearch.watcher.condition.ConditionModule; import org.elasticsearch.watcher.execution.ExecutionModule; @@ -27,15 +28,7 @@ import org.elasticsearch.watcher.history.HistoryModule; import org.elasticsearch.watcher.input.InputModule; import org.elasticsearch.watcher.license.LicenseModule; import org.elasticsearch.watcher.license.LicenseService; -import org.elasticsearch.watcher.rest.action.RestAckWatchAction; -import org.elasticsearch.watcher.rest.action.RestDeleteWatchAction; -import org.elasticsearch.watcher.rest.action.RestExecuteWatchAction; -import org.elasticsearch.watcher.rest.action.RestGetWatchAction; -import org.elasticsearch.watcher.rest.action.RestHijackOperationAction; -import org.elasticsearch.watcher.rest.action.RestPutWatchAction; -import org.elasticsearch.watcher.rest.action.RestWatchServiceAction; -import org.elasticsearch.watcher.rest.action.RestWatcherInfoAction; -import org.elasticsearch.watcher.rest.action.RestWatcherStatsAction; +import org.elasticsearch.watcher.rest.action.*; import org.elasticsearch.watcher.shield.ShieldIntegration; import org.elasticsearch.watcher.shield.WatcherShieldModule; import org.elasticsearch.watcher.shield.WatcherUserHolder; @@ -142,6 +135,7 @@ public class WatcherPlugin extends Plugin { InitializingService.class, LicenseService.class, InternalEmailService.class, + InternalHipChatService.class, HttpClient.class, WatcherSettingsValidation.class); } diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/Action.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/Action.java index ee051d60ec3..d1dde257771 100644 --- a/watcher/src/main/java/org/elasticsearch/watcher/actions/Action.java +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/Action.java @@ -25,6 +25,7 @@ public interface Action extends ToXContent { public enum Status implements ToXContent { SUCCESS, FAILURE, + PARTIAL_FAILURE, THROTTLED, SIMULATED; @@ -95,7 +96,6 @@ public interface Action extends ToXContent { } interface Field { - ParseField STATUS = new ParseField("status"); ParseField REASON = new ParseField("reason"); } } diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/ActionBuilders.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/ActionBuilders.java index e38762ee32a..e1b5eb2755f 100644 --- a/watcher/src/main/java/org/elasticsearch/watcher/actions/ActionBuilders.java +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/ActionBuilders.java @@ -7,6 +7,7 @@ package org.elasticsearch.watcher.actions; import org.elasticsearch.watcher.actions.email.EmailAction; import org.elasticsearch.watcher.actions.email.service.EmailTemplate; +import org.elasticsearch.watcher.actions.hipchat.HipChatAction; import org.elasticsearch.watcher.actions.index.IndexAction; import org.elasticsearch.watcher.actions.logging.LoggingAction; import org.elasticsearch.watcher.actions.webhook.WebhookAction; @@ -53,4 +54,27 @@ public final class ActionBuilders { return LoggingAction.builder(text); } + public static HipChatAction.Builder hipchatAction(String message) { + return hipchatAction(Template.inline(message)); + } + + public static HipChatAction.Builder hipchatAction(String account, String body) { + return hipchatAction(account, Template.inline(body)); + } + + public static HipChatAction.Builder hipchatAction(Template.Builder body) { + return hipchatAction(body.build()); + } + + public static HipChatAction.Builder hipchatAction(String account, Template.Builder body) { + return hipchatAction(account, body.build()); + } + + public static HipChatAction.Builder hipchatAction(Template body) { + return hipchatAction(null, body); + } + + public static HipChatAction.Builder hipchatAction(String account, Template body) { + return HipChatAction.builder(account, body); + } } diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/WatcherActionModule.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/WatcherActionModule.java index d2647dbc0ab..4733fb2a819 100644 --- a/watcher/src/main/java/org/elasticsearch/watcher/actions/WatcherActionModule.java +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/WatcherActionModule.java @@ -12,6 +12,10 @@ import org.elasticsearch.watcher.actions.email.EmailActionFactory; import org.elasticsearch.watcher.actions.email.service.EmailService; import org.elasticsearch.watcher.actions.email.service.HtmlSanitizer; import org.elasticsearch.watcher.actions.email.service.InternalEmailService; +import org.elasticsearch.watcher.actions.hipchat.HipChatAction; +import org.elasticsearch.watcher.actions.hipchat.HipChatActionFactory; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatService; +import org.elasticsearch.watcher.actions.hipchat.service.InternalHipChatService; import org.elasticsearch.watcher.actions.index.IndexAction; import org.elasticsearch.watcher.actions.index.IndexActionFactory; import org.elasticsearch.watcher.actions.logging.LoggingAction; @@ -28,6 +32,14 @@ public class WatcherActionModule extends AbstractModule { private final Map> parsers = new HashMap<>(); + public WatcherActionModule() { + registerAction(EmailAction.TYPE, EmailActionFactory.class); + registerAction(WebhookAction.TYPE, WebhookActionFactory.class); + registerAction(IndexAction.TYPE, IndexActionFactory.class); + registerAction(LoggingAction.TYPE, LoggingActionFactory.class); + registerAction(HipChatAction.TYPE, HipChatActionFactory.class); + } + public void registerAction(String type, Class parserType) { parsers.put(type, parserType); } @@ -36,27 +48,17 @@ public class WatcherActionModule extends AbstractModule { protected void configure() { MapBinder parsersBinder = MapBinder.newMapBinder(binder(), String.class, ActionFactory.class); - - bind(EmailActionFactory.class).asEagerSingleton(); - parsersBinder.addBinding(EmailAction.TYPE).to(EmailActionFactory.class); - - bind(WebhookActionFactory.class).asEagerSingleton(); - parsersBinder.addBinding(WebhookAction.TYPE).to(WebhookActionFactory.class); - - bind(IndexActionFactory.class).asEagerSingleton(); - parsersBinder.addBinding(IndexAction.TYPE).to(IndexActionFactory.class); - - bind(LoggingActionFactory.class).asEagerSingleton(); - parsersBinder.addBinding(LoggingAction.TYPE).to(LoggingActionFactory.class); - for (Map.Entry> entry : parsers.entrySet()) { bind(entry.getValue()).asEagerSingleton(); parsersBinder.addBinding(entry.getKey()).to(entry.getValue()); } bind(ActionRegistry.class).asEagerSingleton(); + bind(HtmlSanitizer.class).asEagerSingleton(); bind(EmailService.class).to(InternalEmailService.class).asEagerSingleton(); + + bind(HipChatService.class).to(InternalHipChatService.class).asEagerSingleton(); } diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/email/service/InternalEmailService.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/email/service/InternalEmailService.java index 2b41648c271..92bf6e1a1b7 100644 --- a/watcher/src/main/java/org/elasticsearch/watcher/actions/email/service/InternalEmailService.java +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/email/service/InternalEmailService.java @@ -79,13 +79,15 @@ public class InternalEmailService extends AbstractLifecycleComponent { + + private final TemplateEngine templateEngine; + private final HipChatService hipchatService; + + public ExecutableHipChatAction(HipChatAction action, ESLogger logger, HipChatService hipchatService, TemplateEngine templateEngine) { + super(action, logger); + this.hipchatService = hipchatService; + this.templateEngine = templateEngine; + } + + @Override + public Action.Result execute(final String actionId, WatchExecutionContext ctx, Payload payload) throws Exception { + + HipChatAccount account = action.account != null ? + hipchatService.getAccount(action.account) : + hipchatService.getDefaultAccount(); + + // lets validate the message again, in case the hipchat service were updated since the + // watch/action were created. + account.validateParsedTemplate(ctx.id().watchId(), actionId, action.message); + + Map model = Variables.createCtxModel(ctx, payload); + HipChatMessage message = account.render(ctx.id().watchId(), actionId, templateEngine, action.message, model); + + if (ctx.simulateAction(actionId)) { + return new HipChatAction.Result.Simulated(message); + } + + SentMessages sentMessages = account.send(message); + return new HipChatAction.Result.Executed(sentMessages); + } + +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/HipChatAction.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/HipChatAction.java new file mode 100644 index 00000000000..d4b5b375168 --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/HipChatAction.java @@ -0,0 +1,257 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat; + + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.ParseFieldMatcher; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.watcher.actions.Action; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatMessage; +import org.elasticsearch.watcher.actions.hipchat.service.SentMessages; +import org.elasticsearch.watcher.support.template.Template; + +import javax.annotation.Nullable; +import java.io.IOException; + +/** + * + */ +public class HipChatAction implements Action { + + public static final String TYPE = "hipchat"; + + final @Nullable String account; + final HipChatMessage.Template message; + + public HipChatAction(@Nullable String account, HipChatMessage.Template message) { + this.account = account; + this.message = message; + } + + @Override + public String type() { + return TYPE; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + HipChatAction that = (HipChatAction) o; + + if (!account.equals(that.account)) return false; + return message.equals(that.message); + } + + @Override + public int hashCode() { + int result = account.hashCode(); + result = 31 * result + message.hashCode(); + return result; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + if (account != null) { + builder.field(Field.ACCOUNT.getPreferredName(), account); + } + builder.field(Field.MESSAGE.getPreferredName(), message); + return builder.endObject(); + } + + public static HipChatAction parse(String watchId, String actionId, XContentParser parser) throws IOException { + String account = null; + HipChatMessage.Template message = null; + + String currentFieldName = null; + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (ParseFieldMatcher.STRICT.match(currentFieldName, Field.ACCOUNT)) { + if (token == XContentParser.Token.VALUE_STRING) { + account = parser.text(); + } else { + throw new ElasticsearchParseException("failed to parse [{}] action [{}/{}]. expected [{}] to be of type string, but found [{}] instead", TYPE, watchId, actionId, Field.ACCOUNT.getPreferredName(), token); + } + } else if (ParseFieldMatcher.STRICT.match(currentFieldName, Field.MESSAGE)) { + try { + message = HipChatMessage.Template.parse(parser); + } catch (Exception e) { + throw new ElasticsearchParseException("failed to parse [{}] action [{}/{}]. failed to parse [{}] field", e, TYPE, watchId, actionId, Field.MESSAGE.getPreferredName()); + } + } else { + throw new ElasticsearchParseException("failed to parse [{}] action [{}/{}]. unexpected token [{}]", TYPE, watchId, actionId, token); + } + } + + if (message == null) { + throw new ElasticsearchParseException("failed to parse [{}] action [{}/{}]. missing required [{}] field", TYPE, watchId, actionId, Field.MESSAGE.getPreferredName()); + } + + return new HipChatAction(account, message); + } + + public static Builder builder(String account, Template body) { + return new Builder(account, body); + } + + public interface Result { + + class Executed extends Action.Result implements Result { + + private final SentMessages sentMessages; + + public Executed(SentMessages sentMessages) { + super(TYPE, status(sentMessages)); + this.sentMessages = sentMessages; + } + + public SentMessages sentMessages() { + return sentMessages; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.field(type, sentMessages, params); + } + + static Status status(SentMessages sentMessages) { + boolean hasSuccesses = false; + boolean hasFailures = false; + for (SentMessages.SentMessage message : sentMessages) { + if (message.successful()) { + hasSuccesses = true; + } else { + hasFailures = true; + } + if (hasFailures && hasSuccesses) { + return Status.PARTIAL_FAILURE; + } + } + return hasFailures ? Status.FAILURE : Status.SUCCESS; + } + } + + class Simulated extends Action.Result implements Result { + + private final HipChatMessage message; + + protected Simulated(HipChatMessage message) { + super(TYPE, Status.SIMULATED); + this.message = message; + } + + public HipChatMessage getMessage() { + return message; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject(type) + .field(Field.MESSAGE.getPreferredName(), message, params) + .endObject(); + } + } + } + + public static class Builder implements Action.Builder { + + final String account; + final HipChatMessage.Template.Builder messageBuilder; + + public Builder(String account, Template body) { + this.account = account; + this.messageBuilder = new HipChatMessage.Template.Builder(body); + } + + public Builder addRooms(org.elasticsearch.watcher.support.template.Template... rooms) { + messageBuilder.addRooms(rooms); + return this; + } + + public Builder addRooms(org.elasticsearch.watcher.support.template.Template.Builder... rooms) { + org.elasticsearch.watcher.support.template.Template[] templates = new org.elasticsearch.watcher.support.template.Template[rooms.length]; + for (int i = 0; i < rooms.length; i++) { + templates[i] = rooms[i].build(); + } + return addRooms(templates); + } + + public Builder addRooms(String... rooms) { + org.elasticsearch.watcher.support.template.Template[] templates = new org.elasticsearch.watcher.support.template.Template[rooms.length]; + for (int i = 0; i < rooms.length; i++) { + templates[i] = Template.inline(rooms[i]).build(); + } + return addRooms(templates); + } + + + public Builder addUsers(org.elasticsearch.watcher.support.template.Template... users) { + messageBuilder.addUsers(users); + return this; + } + + public Builder addUsers(org.elasticsearch.watcher.support.template.Template.Builder... users) { + org.elasticsearch.watcher.support.template.Template[] templates = new org.elasticsearch.watcher.support.template.Template[users.length]; + for (int i = 0; i < users.length; i++) { + templates[i] = users[i].build(); + } + return addUsers(templates); + } + + public Builder addUsers(String... users) { + org.elasticsearch.watcher.support.template.Template[] templates = new org.elasticsearch.watcher.support.template.Template[users.length]; + for (int i = 0; i < users.length; i++) { + templates[i] = Template.inline(users[i]).build(); + } + return addUsers(templates); + } + + public Builder setFrom(String from) { + messageBuilder.setFrom(from); + return this; + } + + public Builder setFormat(HipChatMessage.Format format) { + messageBuilder.setFormat(format); + return this; + } + + public Builder setColor(org.elasticsearch.watcher.support.template.Template color) { + messageBuilder.setColor(color); + return this; + } + + public Builder setColor(org.elasticsearch.watcher.support.template.Template.Builder color) { + return setColor(color.build()); + } + + public Builder setColor(HipChatMessage.Color color) { + return setColor(color.asTemplate()); + } + + public Builder setNotify(boolean notify) { + messageBuilder.setNotify(notify); + return this; + } + + @Override + public HipChatAction build() { + return new HipChatAction(account, messageBuilder.build()); + } + } + + public interface Field { + ParseField ACCOUNT = new ParseField("account"); + ParseField MESSAGE = new ParseField("message"); + } +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionFactory.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionFactory.java new file mode 100644 index 00000000000..b2fb240ec9e --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionFactory.java @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.logging.Loggers; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.watcher.actions.ActionFactory; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatAccount; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatService; +import org.elasticsearch.watcher.support.template.TemplateEngine; + +import java.io.IOException; + +/** + * + */ +public class HipChatActionFactory extends ActionFactory { + + private final TemplateEngine templateEngine; + private final HipChatService hipchatService; + + @Inject + public HipChatActionFactory(Settings settings, TemplateEngine templateEngine, HipChatService hipchatService) { + super(Loggers.getLogger(ExecutableHipChatAction.class, settings)); + this.templateEngine = templateEngine; + this.hipchatService = hipchatService; + } + + @Override + public String type() { + return HipChatAction.TYPE; + } + + @Override + public HipChatAction parseAction(String watchId, String actionId, XContentParser parser) throws IOException { + HipChatAction action = HipChatAction.parse(watchId, actionId, parser); + HipChatAccount account = hipchatService.getAccount(action.account); + if (account == null) { + throw new ElasticsearchParseException("could not parse [hipchat] action [{}/{}]. unknown hipchat account [{}]", watchId, account, action.account); + } + account.validateParsedTemplate(watchId, actionId, action.message); + return action; + } + + @Override + public ExecutableHipChatAction createExecutable(HipChatAction action) { + return new ExecutableHipChatAction(action, actionLogger, hipchatService, templateEngine); + } + +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccount.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccount.java new file mode 100644 index 00000000000..7f50a82d0e8 --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccount.java @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.watcher.support.http.HttpClient; +import org.elasticsearch.watcher.support.template.TemplateEngine; + +import java.io.IOException; +import java.util.Locale; +import java.util.Map; + +/** + * + */ +public abstract class HipChatAccount { + + public static final String AUTH_TOKEN_SETTING = "auth_token"; + public static final String ROOM_SETTING = HipChatMessage.Field.ROOM.getPreferredName(); + public static final String DEFAULT_ROOM_SETTING = "message_defaults." + HipChatMessage.Field.ROOM.getPreferredName(); + public static final String DEFAULT_USER_SETTING = "message_defaults." + HipChatMessage.Field.USER.getPreferredName(); + public static final String DEFAULT_FROM_SETTING = "message_defaults." + HipChatMessage.Field.FROM.getPreferredName(); + public static final String DEFAULT_FORMAT_SETTING = "message_defaults." + HipChatMessage.Field.FORMAT.getPreferredName(); + public static final String DEFAULT_COLOR_SETTING = "message_defaults." + HipChatMessage.Field.COLOR.getPreferredName(); + public static final String DEFAULT_NOTIFY_SETTING = "message_defaults." + HipChatMessage.Field.NOTIFY.getPreferredName(); + + protected final ESLogger logger; + protected final String name; + protected final Profile profile; + protected final HipChatServer server; + protected final HttpClient httpClient; + protected final String authToken; + + protected HipChatAccount(String name, Profile profile, Settings settings, HipChatServer defaultServer, HttpClient httpClient, ESLogger logger) { + this.name = name; + this.profile = profile; + this.server = new HipChatServer(settings, defaultServer); + this.httpClient = httpClient; + this.authToken = settings.get(AUTH_TOKEN_SETTING); + if (this.authToken == null || this.authToken.length() == 0) { + throw new SettingsException("hipchat account [" + name + "] missing required [" + AUTH_TOKEN_SETTING + "] setting"); + } + this.logger = logger; + } + + public abstract String type(); + + public abstract void validateParsedTemplate(String watchId, String actionId, HipChatMessage.Template message) throws SettingsException; + + public abstract HipChatMessage render(String watchId, String actionId, TemplateEngine engine, HipChatMessage.Template template, Map model); + + public abstract SentMessages send(HipChatMessage message); + + enum Profile implements ToXContent { + + V1() { + @Override + HipChatAccount createAccount(String name, Settings settings, HipChatServer defaultServer, HttpClient httpClient, ESLogger logger) { + return new V1Account(name, settings, defaultServer, httpClient, logger); + } + }, + INTEGRATION() { + @Override + HipChatAccount createAccount(String name, Settings settings, HipChatServer defaultServer, HttpClient httpClient, ESLogger logger) { + return new IntegrationAccount(name, settings, defaultServer, httpClient, logger); + } + }, + USER() { + @Override + HipChatAccount createAccount(String name, Settings settings, HipChatServer defaultServer, HttpClient httpClient, ESLogger logger) { + return new UserAccount(name, settings, defaultServer, httpClient, logger); + } + }; + + abstract HipChatAccount createAccount(String name, Settings settings, HipChatServer defaultServer, HttpClient httpClient, ESLogger logger); + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.value(name().toLowerCase(Locale.ROOT)); + } + + public String value() { + return name().toLowerCase(Locale.ROOT); + } + + public static Profile parse(XContentParser parser) throws IOException { + return Profile.valueOf(parser.text().toUpperCase(Locale.ROOT)); + } + + public static Profile resolve(String value, Profile defaultValue) { + if (value == null) { + return defaultValue; + } + return Profile.valueOf(value.toUpperCase(Locale.ROOT)); + } + + public static Profile resolve(Settings settings, String setting, Profile defaultValue) { + return resolve(settings.get(setting), defaultValue); + } + + public static boolean validate(String value) { + try { + Profile.valueOf(value.toUpperCase(Locale.ROOT)); + return true; + } catch (IllegalArgumentException ilae) { + return false; + } + } + + } + +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccounts.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccounts.java new file mode 100644 index 00000000000..2a73524c35a --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccounts.java @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatAccount.Profile; +import org.elasticsearch.watcher.support.http.HttpClient; + +import java.util.HashMap; +import java.util.Map; + +/** + * + */ +public class HipChatAccounts { + + private final Map accounts; + private final String defaultAccountName; + + public HipChatAccounts(Settings settings, HttpClient httpClient, ESLogger logger) { + HipChatServer defaultServer = new HipChatServer(settings); + Settings accountsSettings = settings.getAsSettings("account"); + accounts = new HashMap<>(); + for (String name : accountsSettings.names()) { + Settings accountSettings = accountsSettings.getAsSettings(name); + Profile profile = Profile.resolve(accountSettings, "profile", null); + if (profile == null) { + throw new SettingsException("missing [profile] setting for hipchat account [" + name + "]"); + } + HipChatAccount account = profile.createAccount(name, accountSettings, defaultServer, httpClient, logger); + accounts.put(name, account); + } + + String defaultAccountName = settings.get("default_account"); + if (defaultAccountName == null) { + if (accounts.isEmpty()) { + this.defaultAccountName = null; + } else { + HipChatAccount account = accounts.values().iterator().next(); + logger.info("default hipchat account set to [{}]", account.name); + this.defaultAccountName = account.name; + } + } else if (!accounts.containsKey(defaultAccountName)) { + throw new SettingsException("could not find default hipchat account [" + defaultAccountName + "]"); + } else { + this.defaultAccountName = defaultAccountName; + } + } + + /** + * Returns the account associated with the given name. If there is not such account, {@code null} is returned. + * If the given name is {@code null}, the default account will be returned. + * + * @param name The name of the requested account + * @return The account associated with the given name, or {@code null} when requested an unkonwn account. + * @throws IllegalStateException if the name is null and the default account is null. + */ + public HipChatAccount account(String name) throws IllegalStateException { + if (name == null) { + if (defaultAccountName == null) { + throw new IllegalStateException("cannot find default hipchat account as no accounts have been configured"); + } + name = defaultAccountName; + } + return accounts.get(name); + } +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatMessage.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatMessage.java new file mode 100644 index 00000000000..cb73a43b5cb --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatMessage.java @@ -0,0 +1,489 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.ParseFieldMatcher; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.watcher.support.template.TemplateEngine; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.*; + +/** + * + */ +public class HipChatMessage implements ToXContent { + + final String body; + final @Nullable String[] rooms; + final @Nullable String[] users; + final @Nullable String from; + final @Nullable Format format; + final @Nullable Color color; + final @Nullable Boolean notify; + + public HipChatMessage(String body, String[] rooms, String[] users, String from, Format format, Color color, Boolean notify) { + this.body = body; + this.rooms = rooms; + this.users = users; + this.from = from; + this.format = format; + this.color = color; + this.notify = notify; + } + + public String getBody() { + return body; + } + + public String[] getRooms() { + return rooms; + } + + @Nullable + public String[] getUsers() { + return users; + } + + @Nullable + public String getFrom() { + return from; + } + + @Nullable + public Format getFormat() { + return format; + } + + @Nullable + public Color getColor() { + return color; + } + + @Nullable + public Boolean getNotify() { + return notify; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + HipChatMessage that = (HipChatMessage) o; + + if (!body.equals(that.body)) return false; + if (!Arrays.equals(rooms, that.rooms)) return false; + if (!Arrays.equals(users, that.users)) return false; + if (from != null ? !from.equals(that.from) : that.from != null) return false; + if (format != that.format) return false; + if (color != that.color) return false; + return !(notify != null ? !notify.equals(that.notify) : that.notify != null); + } + + @Override + public int hashCode() { + int result = body.hashCode(); + result = 31 * result + (rooms != null ? Arrays.hashCode(rooms) : 0); + result = 31 * result + (users != null ? Arrays.hashCode(users) : 0); + result = 31 * result + (from != null ? from.hashCode() : 0); + result = 31 * result + (format != null ? format.hashCode() : 0); + result = 31 * result + (color != null ? color.hashCode() : 0); + result = 31 * result + (notify != null ? notify.hashCode() : 0); + return result; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return toXContent(builder, params, true); + } + + public XContentBuilder toXContent(XContentBuilder builder, Params params, boolean includeTargets) throws IOException { + builder.startObject(); + if (from != null) { + builder.field(Field.FROM.getPreferredName(), from); + } + if (includeTargets) { + if (rooms != null && rooms.length > 0) { + builder.array(Field.ROOM.getPreferredName(), rooms); + } + if (users != null && users.length > 0) { + builder.array(Field.USER.getPreferredName(), users); + } + } + builder.field(Field.BODY.getPreferredName(), body); + if (format != null) { + builder.field(Field.FORMAT.getPreferredName(), format, params); + } + if (color != null) { + builder.field(Field.COLOR.getPreferredName(), color, params); + } + if (notify != null) { + builder.field(Field.NOTIFY.getPreferredName(), notify); + } + return builder.endObject(); + } + + public static class Template implements ToXContent { + + final org.elasticsearch.watcher.support.template.Template body; + final @Nullable org.elasticsearch.watcher.support.template.Template[] rooms; + final @Nullable org.elasticsearch.watcher.support.template.Template[] users; + final @Nullable String from; + final @Nullable Format format; + final @Nullable org.elasticsearch.watcher.support.template.Template color; + final @Nullable Boolean notify; + + public Template(org.elasticsearch.watcher.support.template.Template body, + org.elasticsearch.watcher.support.template.Template[] rooms, + org.elasticsearch.watcher.support.template.Template[] users, + String from, + Format format, + org.elasticsearch.watcher.support.template.Template color, + Boolean notify) { + this.rooms = rooms; + this.users = users; + this.body = body; + this.from = from; + this.format = format; + this.color = color; + this.notify = notify; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Template template = (Template) o; + + if (!body.equals(template.body)) return false; + if (!Arrays.equals(rooms, template.rooms)) return false; + if (!Arrays.equals(users, template.users)) return false; + if (from != null ? !from.equals(template.from) : template.from != null) return false; + if (format != template.format) return false; + if (color != null ? !color.equals(template.color) : template.color != null) return false; + return !(notify != null ? !notify.equals(template.notify) : template.notify != null); + } + + @Override + public int hashCode() { + int result = body.hashCode(); + result = 31 * result + (rooms != null ? Arrays.hashCode(rooms) : 0); + result = 31 * result + (users != null ? Arrays.hashCode(users) : 0); + result = 31 * result + (from != null ? from.hashCode() : 0); + result = 31 * result + (format != null ? format.hashCode() : 0); + result = 31 * result + (color != null ? color.hashCode() : 0); + result = 31 * result + (notify != null ? notify.hashCode() : 0); + return result; + } + + public HipChatMessage render(TemplateEngine engine, Map model) { + String body = engine.render(this.body, model); + String[] rooms = null; + if (this.rooms != null) { + rooms = new String[this.rooms.length]; + for (int i = 0; i < this.rooms.length; i++) { + rooms[i] = engine.render(this.rooms[i], model); + } + } + String[] users = null; + if (this.users != null) { + users = new String[this.users.length]; + for (int i = 0; i < this.users.length; i++) { + users[i] = engine.render(this.users[i], model); + } + } + Color color = this.color == null ? null : Color.resolve(engine.render(this.color, model), null); + return new HipChatMessage(body, rooms, users, from, format, color, notify); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + if (from != null) { + builder.field(Field.FROM.getPreferredName(), from); + } + if (rooms != null && rooms.length > 0) { + builder.startArray(Field.ROOM.getPreferredName()); + for (org.elasticsearch.watcher.support.template.Template room : rooms) { + room.toXContent(builder, params); + } + builder.endArray(); + } + if (users != null && users.length > 0) { + builder.startArray(Field.USER.getPreferredName()); + for (org.elasticsearch.watcher.support.template.Template user : users) { + user.toXContent(builder, params); + } + builder.endArray(); + } + builder.field(Field.BODY.getPreferredName(), body, params); + if (format != null) { + builder.field(Field.FORMAT.getPreferredName(), format, params); + } + if (color != null) { + builder.field(Field.COLOR.getPreferredName(), color, params); + } + if (notify != null) { + builder.field(Field.NOTIFY.getPreferredName(), notify); + } + return builder.endObject(); + } + + public static Template parse(XContentParser parser) throws IOException { + org.elasticsearch.watcher.support.template.Template body = null; + org.elasticsearch.watcher.support.template.Template[] rooms = null; + org.elasticsearch.watcher.support.template.Template[] users = null; + String from = null; + org.elasticsearch.watcher.support.template.Template color = null; + Boolean notify = null; + HipChatMessage.Format messageFormat = null; + + String currentFieldName = null; + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (ParseFieldMatcher.STRICT.match(currentFieldName, Field.FROM)) { + from = parser.text(); + } else if (ParseFieldMatcher.STRICT.match(currentFieldName, Field.ROOM)) { + List templates = new ArrayList<>(); + if (token == XContentParser.Token.START_ARRAY) { + while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) { + try { + templates.add(org.elasticsearch.watcher.support.template.Template.parse(parser)); + } catch (ElasticsearchParseException epe) { + throw new ElasticsearchParseException("failed to parse hipchat message. failed to parse [{}] field", epe, Field.ROOM.getPreferredName()); + } + } + } else { + try { + templates.add(org.elasticsearch.watcher.support.template.Template.parse(parser)); + } catch (ElasticsearchParseException epe) { + throw new ElasticsearchParseException("failed to parse hipchat message. failed to parse [{}] field", epe, Field.ROOM.getPreferredName()); + } + } + rooms = templates.toArray(new org.elasticsearch.watcher.support.template.Template[templates.size()]); + } else if (ParseFieldMatcher.STRICT.match(currentFieldName, Field.USER)) { + List templates = new ArrayList<>(); + if (token == XContentParser.Token.START_ARRAY) { + while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) { + try { + templates.add(org.elasticsearch.watcher.support.template.Template.parse(parser)); + } catch (ElasticsearchParseException epe) { + throw new ElasticsearchParseException("failed to parse hipchat message. failed to parse [{}] field", epe, Field.USER.getPreferredName()); + } + } + } else { + try { + templates.add(org.elasticsearch.watcher.support.template.Template.parse(parser)); + } catch (ElasticsearchParseException epe) { + throw new ElasticsearchParseException("failed to parse hipchat message. failed to parse [{}] field", epe, Field.USER.getPreferredName()); + } + } + users = templates.toArray(new org.elasticsearch.watcher.support.template.Template[templates.size()]); + } else if (ParseFieldMatcher.STRICT.match(currentFieldName, Field.COLOR)) { + try { + color = org.elasticsearch.watcher.support.template.Template.parse(parser); + } catch (ElasticsearchParseException | IllegalArgumentException e) { + throw new ElasticsearchParseException("failed to parse hipchat message. failed to parse [{}] field", e, Field.COLOR.getPreferredName()); + } + } else if (ParseFieldMatcher.STRICT.match(currentFieldName, Field.NOTIFY)) { + if (token == XContentParser.Token.VALUE_BOOLEAN) { + notify = parser.booleanValue(); + } else { + throw new ElasticsearchParseException("failed to parse hipchat message. failed to parse [{}] field, expected a boolean value but found [{}]", Field.NOTIFY.getPreferredName(), token); + } + } else if (ParseFieldMatcher.STRICT.match(currentFieldName, Field.BODY)) { + try { + body = org.elasticsearch.watcher.support.template.Template.parse(parser); + } catch (ElasticsearchParseException pe) { + throw new ElasticsearchParseException("failed to parse hipchat message. failed to parse [{}] field", pe, Field.BODY.getPreferredName()); + } + } else if (ParseFieldMatcher.STRICT.match(currentFieldName, Field.FORMAT)) { + try { + messageFormat = HipChatMessage.Format.parse(parser); + } catch (IllegalArgumentException ilae) { + throw new ElasticsearchParseException("failed to parse hipchat message. failed to parse [{}] field", ilae, Field.FORMAT.getPreferredName()); + } + } else { + throw new ElasticsearchParseException("failed to parse hipchat message. unexpected token [{}]", token); + } + } + + if (body == null) { + throw new ElasticsearchParseException("failed to parse hipchat message. missing required [{}] field", Field.BODY.getPreferredName()); + } + + return new HipChatMessage.Template(body, rooms, users, from, messageFormat, color, notify); + } + + public static class Builder { + + final org.elasticsearch.watcher.support.template.Template body; + final List rooms = new ArrayList<>(); + final List users = new ArrayList<>(); + @Nullable String from; + @Nullable Format format; + @Nullable org.elasticsearch.watcher.support.template.Template color; + @Nullable Boolean notify; + + public Builder(org.elasticsearch.watcher.support.template.Template body) { + this.body = body; + } + + public Builder addRooms(org.elasticsearch.watcher.support.template.Template... rooms) { + this.rooms.addAll(Arrays.asList(rooms)); + return this; + } + + public Builder addUsers(org.elasticsearch.watcher.support.template.Template... users) { + this.users.addAll(Arrays.asList(users)); + return this; + } + + public Builder setFrom(String from) { + this.from = from; + return this; + } + + public Builder setFormat(Format format) { + this.format = format; + return this; + } + + public Builder setColor(org.elasticsearch.watcher.support.template.Template color) { + this.color = color; + return this; + } + + public Builder setNotify(boolean notify) { + this.notify = notify; + return this; + } + + public Template build() { + return new Template( + body, + rooms.isEmpty() ? null : rooms.toArray(new org.elasticsearch.watcher.support.template.Template[rooms.size()]), + users.isEmpty() ? null : users.toArray(new org.elasticsearch.watcher.support.template.Template[users.size()]), + from, + format, + color, + notify); + } + } + } + + + public enum Color implements ToXContent { + YELLOW, GREEN, RED, PURPLE, GRAY, RANDOM; + + private final org.elasticsearch.watcher.support.template.Template template = org.elasticsearch.watcher.support.template.Template.inline(name()).build(); + + public org.elasticsearch.watcher.support.template.Template asTemplate() { + return template; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.value(name().toLowerCase(Locale.ROOT)); + } + + public String value() { + return name().toLowerCase(Locale.ROOT); + } + + public static Color parse(XContentParser parser) throws IOException { + return Color.valueOf(parser.text().toUpperCase(Locale.ROOT)); + } + + public static Color resolve(String value, Color defaultValue) { + if (value == null) { + return defaultValue; + } + return Color.valueOf(value.toUpperCase(Locale.ROOT)); + } + + public static Color resolve(Settings settings, String setting, Color defaultValue) { + return resolve(settings.get(setting), defaultValue); + } + + public static boolean validate(String value) { + try { + Color.valueOf(value.toUpperCase(Locale.ROOT)); + return true; + } catch (IllegalArgumentException ilae) { + return false; + } + } + } + + /** + * + */ + public enum Format implements ToXContent { + + TEXT, + HTML; + + private final org.elasticsearch.watcher.support.template.Template template = org.elasticsearch.watcher.support.template.Template.inline(name()).build(); + + public org.elasticsearch.watcher.support.template.Template asTemplate() { + return template; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.value(name().toLowerCase(Locale.ROOT)); + } + + public String value() { + return name().toLowerCase(Locale.ROOT); + } + + public static Format parse(XContentParser parser) throws IOException { + return Format.valueOf(parser.text().toUpperCase(Locale.ROOT)); + } + + public static Format resolve(String value, Format defaultValue) { + if (value == null) { + return defaultValue; + } + return Format.valueOf(value.toUpperCase(Locale.ROOT)); + } + + public static Format resolve(Settings settings, String setting, Format defaultValue) { + return resolve(settings.get(setting), defaultValue); + } + + public static boolean validate(String value) { + try { + Format.valueOf(value.toUpperCase(Locale.ROOT)); + return true; + } catch (IllegalArgumentException ilae) { + return false; + } + } + } + + public interface Field { + ParseField ROOM = new ParseField("room"); + ParseField USER = new ParseField("user"); + ParseField BODY = new ParseField("body"); + ParseField FROM = new ParseField("from"); + ParseField COLOR = new ParseField("color"); + ParseField NOTIFY = new ParseField("notify"); + ParseField FORMAT = new ParseField("format"); + } +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatServer.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatServer.java new file mode 100644 index 00000000000..7ab2dd47a55 --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatServer.java @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.watcher.support.http.HttpRequest; + +/** + * + */ +public class HipChatServer { + + public static final String HOST_SETTING = "host"; + public static final String PORT_SETTING = "port"; + + public static final HipChatServer DEFAULT = new HipChatServer("api.hipchat.com", 443, null); + + private final String host; + private final int port; + private final HipChatServer fallback; + + public HipChatServer(Settings settings) { + this(settings, DEFAULT); + } + + public HipChatServer(Settings settings, HipChatServer fallback) { + this(settings.get(HOST_SETTING, null), settings.getAsInt(PORT_SETTING, -1), fallback); + } + + public HipChatServer(String host, int port, HipChatServer fallback) { + this.host = host; + this.port = port; + this.fallback = fallback; + } + + public String host() { + return host != null ? host : fallback.host(); + } + + public int port() { + return port > 0 ? port : fallback.port(); + } + + public HipChatServer fallback() { + return fallback != null ? fallback : DEFAULT; + } + + public HipChatServer rebuild(Settings settings, HipChatServer fallback) { + return new HipChatServer(settings.get(HOST_SETTING, host), settings.getAsInt(PORT_SETTING, port), fallback); + } + + public synchronized HttpRequest.Builder httpRequest() { + return HttpRequest.builder(host(), port()); + } + +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatService.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatService.java new file mode 100644 index 00000000000..8f3b5ea86ec --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatService.java @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +/** + * + */ +public interface HipChatService { + + /** + * @return The default hipchat account. + */ + HipChatAccount getDefaultAccount(); + + /** + * @return The account identified by the given name. If the given name is {@code null} the default + * account will be returned. + */ + HipChatAccount getAccount(String accountName); + +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/IntegrationAccount.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/IntegrationAccount.java new file mode 100644 index 00000000000..a3a88cef7e3 --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/IntegrationAccount.java @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.watcher.actions.hipchat.HipChatAction; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatMessage.Color; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatMessage.Format; +import org.elasticsearch.watcher.support.http.*; +import org.elasticsearch.watcher.support.template.TemplateEngine; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * + */ +public class IntegrationAccount extends HipChatAccount { + + public static final String TYPE = "integration"; + + final String room; + final Defaults defaults; + + public IntegrationAccount(String name, Settings settings, HipChatServer defaultServer, HttpClient httpClient, ESLogger logger) { + super(name, Profile.INTEGRATION, settings, defaultServer, httpClient, logger); + String[] rooms = settings.getAsArray(ROOM_SETTING, null); + if (rooms == null || rooms.length == 0) { + throw new SettingsException("invalid hipchat account [" + name + "]. missing required [" + ROOM_SETTING + "] setting for [" + TYPE + "] account profile"); + } + if (rooms.length > 1) { + throw new SettingsException("invalid hipchat account [" + name + "]. [" + ROOM_SETTING + "] setting for [" + TYPE + "] account must only be set with a single value"); + } + this.room = rooms[0]; + defaults = new Defaults(settings); + } + + @Override + public String type() { + return TYPE; + } + + @Override + public void validateParsedTemplate(String watchId, String actionId, HipChatMessage.Template template) throws SettingsException { + if (template.rooms != null) { + throw new ElasticsearchParseException("invalid [" + HipChatAction.TYPE + "] action for [" + watchId + "/" + actionId + "] action. [" + name + "] hipchat account doesn't support custom rooms"); + } + if (template.users != null) { + throw new ElasticsearchParseException("invalid [" + HipChatAction.TYPE + "] action for [" + watchId + "/" + actionId + "] action. [" + name + "] hipchat account doesn't support user private messages"); + } + if (template.from != null) { + throw new ElasticsearchParseException("invalid [" + HipChatAction.TYPE + "] action for [" + watchId + "/" + actionId + "] action. [" + name + "] hipchat account doesn't support custom `from` fields"); + } + } + + @Override + public HipChatMessage render(String watchId, String actionId, TemplateEngine engine, HipChatMessage.Template template, Map model) { + String message = engine.render(template.body, model); + Color color = template.color != null ? Color.resolve(engine.render(template.color, model), defaults.color) : defaults.color; + Boolean notify = template.notify != null ? template.notify : defaults.notify; + Format messageFormat = template.format != null ? template.format : defaults.format; + return new HipChatMessage(message, null, null, null, messageFormat, color, notify); + } + + @Override + public SentMessages send(HipChatMessage message) { + List sentMessages = new ArrayList<>(); + HttpRequest request = buildRoomRequest(room, message); + try { + HttpResponse response = httpClient.execute(request); + sentMessages.add(SentMessages.SentMessage.responded(room, SentMessages.SentMessage.TargetType.ROOM, message, request, response)); + } catch (IOException e) { + logger.error("failed to execute hipchat api http request", e); + sentMessages.add(SentMessages.SentMessage.error(room, SentMessages.SentMessage.TargetType.ROOM, message, ExceptionsHelper.detailedMessage(e))); + } + return new SentMessages(name, sentMessages); + } + + public HttpRequest buildRoomRequest(String room, final HipChatMessage message) { + return server.httpRequest() + .method(HttpMethod.POST) + .scheme(Scheme.HTTPS) + .path("/v2/room/" + room + "/notification") + .setHeader("Content-Type", "application/json") + .setHeader("Authorization", "Bearer " + authToken) + .body(XContentHelper.toString(new ToXContent() { + @Override + public XContentBuilder toXContent(XContentBuilder xbuilder, Params params) throws IOException { + xbuilder.field("message", message.body); + if (message.format != null) { + xbuilder.field("message_format", message.format.value()); + } + if (message.notify != null) { + xbuilder.field("notify", message.notify); + } + if (message.color != null) { + xbuilder.field("color", String.valueOf(message.color.value())); + } + return xbuilder; + } + })) + .build(); + } + + static class Defaults { + + final @Nullable Format format; + final @Nullable Color color; + final @Nullable Boolean notify; + + public Defaults(Settings settings) { + this.format = Format.resolve(settings, DEFAULT_FORMAT_SETTING, null); + this.color = Color.resolve(settings, DEFAULT_COLOR_SETTING, null); + this.notify = settings.getAsBoolean(DEFAULT_NOTIFY_SETTING, null); + } + } +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/InternalHipChatService.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/InternalHipChatService.java new file mode 100644 index 00000000000..31b6cd66a58 --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/InternalHipChatService.java @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.component.AbstractLifecycleComponent; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.node.settings.NodeSettingsService; +import org.elasticsearch.watcher.shield.WatcherSettingsFilter; +import org.elasticsearch.watcher.support.http.HttpClient; + +/** + * + */ +public class InternalHipChatService extends AbstractLifecycleComponent implements HipChatService { + + private final HttpClient httpClient; + private volatile HipChatAccounts accounts; + + @Inject + public InternalHipChatService(Settings settings, HttpClient httpClient, NodeSettingsService nodeSettingsService, WatcherSettingsFilter settingsFilter) { + super(settings); + this.httpClient = httpClient; + nodeSettingsService.addListener(new NodeSettingsService.Listener() { + @Override + public void onRefreshSettings(Settings settings) { + reset(settings); + } + }); + settingsFilter.filterOut("watcher.actions.hipchat.service.account.*.auth_token"); + } + + @Override + protected void doStart() { + reset(settings); + } + + @Override + protected void doStop() { + } + + @Override + protected void doClose() { + } + + @Override + public HipChatAccount getDefaultAccount() { + return accounts.account(null); + } + + @Override + public HipChatAccount getAccount(String name) { + return accounts.account(name); + } + + void reset(Settings nodeSettings) { + Settings.Builder builder = Settings.builder(); + String prefix = "watcher.actions.hipchat.service"; + for (String setting : settings.getAsMap().keySet()) { + if (setting.startsWith(prefix)) { + builder.put(setting.substring(prefix.length()+1), settings.get(setting)); + } + } + if (nodeSettings != settings) { // if it's the same settings, no point in re-applying it + for (String setting : nodeSettings.getAsMap().keySet()) { + if (setting.startsWith(prefix)) { + builder.put(setting.substring(prefix.length() + 1), nodeSettings.get(setting)); + } + } + } + accounts = new HipChatAccounts(builder.build(), httpClient, logger); + } +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/SentMessages.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/SentMessages.java new file mode 100644 index 00000000000..45b64b02447 --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/SentMessages.java @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentBuilderString; +import org.elasticsearch.watcher.support.http.HttpRequest; +import org.elasticsearch.watcher.support.http.HttpResponse; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; + +/** + * + */ +public class SentMessages implements ToXContent, Iterable { + + private String accountName; + private List messages; + + public SentMessages(String accountName, List messages) { + this.accountName = accountName; + this.messages = messages; + } + + public String getAccountName() { + return accountName; + } + + @Override + public Iterator iterator() { + return messages.iterator(); + } + + public int count() { + return messages.size(); + } + + public List asList() { + return Collections.unmodifiableList(messages); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(Field.ACCOUNT, accountName); + builder.startArray(Field.SENT_MESSAGES); + for (SentMessage message : messages) { + message.toXContent(builder, params); + } + builder.endArray(); + return builder.endObject(); + } + + public static class SentMessage implements ToXContent { + + public enum TargetType { + ROOM, USER; + + final XContentBuilderString fieldName = new XContentBuilderString(name().toLowerCase(Locale.ROOT)); + } + + final String targetName; + final TargetType targetType; + final HipChatMessage message; + final @Nullable HttpRequest request; + final @Nullable HttpResponse response; + final @Nullable String failureReason; + + public static SentMessage responded(String targetName, TargetType targetType, HipChatMessage message, HttpRequest request, HttpResponse response) { + String failureReason = resolveFailureReason(response); + return new SentMessage(targetName, targetType, message, request, response, failureReason); + } + + public static SentMessage error(String targetName, TargetType targetType, HipChatMessage message, String reason) { + return new SentMessage(targetName, targetType, message, null, null, reason); + } + + private SentMessage(String targetName, TargetType targetType, HipChatMessage message, HttpRequest request, HttpResponse response, String failureReason) { + this.targetName = targetName; + this.targetType = targetType; + this.message = message; + this.request = request; + this.response = response; + this.failureReason = failureReason; + } + + public boolean successful() { + return failureReason == null; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + if (failureReason != null) { + builder.field(Field.STATUS, "failure"); + builder.field(Field.REASON, failureReason); + if (request != null) { + builder.field(Field.REQUEST); + request.toXContent(builder, params); + } + if (response != null) { + builder.field(Field.RESPONSE); + response.toXContent(builder, params); + } + } else { + builder.field(Field.STATUS, "success"); + } + builder.field(targetType.fieldName, targetName); + builder.field(Field.MESSAGE); + message.toXContent(builder, params, false); + return builder.endObject(); + } + + private static String resolveFailureReason(HttpResponse response) { + int status = response.status(); + if (status < 300) { + return null; + } + switch (status) { + case 400: return "Bad Request"; + case 401: return "Unauthorized. The provided authentication token is invalid."; + case 403: return "Forbidden. The account doesn't have permission to send this message."; + case 404: // Not Found + case 405: // Method Not Allowed + case 406: return "The account used invalid HipChat APIs"; // Not Acceptable + case 503: + case 500: return "HipChat Server Error."; + default: + return "Unknown Error"; + } + } + } + + interface Field { + XContentBuilderString ACCOUNT = new XContentBuilderString("account"); + XContentBuilderString SENT_MESSAGES = new XContentBuilderString("sent_messages"); + XContentBuilderString STATUS = new XContentBuilderString("status"); + XContentBuilderString REASON = new XContentBuilderString("reason"); + XContentBuilderString REQUEST = new XContentBuilderString("request"); + XContentBuilderString RESPONSE = new XContentBuilderString("response"); + XContentBuilderString MESSAGE = new XContentBuilderString("message"); + } +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/UserAccount.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/UserAccount.java new file mode 100644 index 00000000000..7260900cb5c --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/UserAccount.java @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.watcher.actions.hipchat.HipChatAction; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatMessage.Color; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatMessage.Format; +import org.elasticsearch.watcher.support.http.*; +import org.elasticsearch.watcher.support.template.TemplateEngine; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * + */ +public class UserAccount extends HipChatAccount { + + public static final String TYPE = "user"; + + final Defaults defaults; + + public UserAccount(String name, Settings settings, HipChatServer defaultServer, HttpClient httpClient, ESLogger logger) { + super(name, Profile.USER, settings, defaultServer, httpClient, logger); + defaults = new Defaults(settings); + } + + @Override + public String type() { + return TYPE; + } + + @Override + public void validateParsedTemplate(String watchId, String actionId, HipChatMessage.Template template) throws SettingsException { + if (template.from != null) { + throw new ElasticsearchParseException("invalid [" + HipChatAction.TYPE + "] action for [" + watchId + "/" + actionId + "]. [" + name + "] hipchat account doesn't support custom `from` fields"); + } + } + + @Override + public HipChatMessage render(String watchId, String actionId, TemplateEngine engine, HipChatMessage.Template template, Map model) { + String[] rooms = defaults.rooms; + if (template.rooms != null) { + rooms = new String[template.rooms.length]; + for (int i = 0; i < template.rooms.length; i++) { + rooms[i] = engine.render(template.rooms[i], model); + } + } + String[] users = defaults.users; + if (template.users != null) { + users = new String[template.users.length]; + for (int i = 0; i < template.users.length; i++) { + users[i] = engine.render(template.users[i], model); + } + } + String message = engine.render(template.body, model); + Color color = Color.resolve(engine.render(template.color, model), defaults.color); + Boolean notify = template.notify != null ? template.notify : defaults.notify; + Format messageFormat = template.format != null ? template.format : defaults.format; + return new HipChatMessage(message, rooms, users, null, messageFormat, color, notify); + } + + @Override + public SentMessages send(HipChatMessage message) { + List sentMessages = new ArrayList<>(); + if (message.rooms != null) { + for (String room : message.rooms) { + HttpRequest request = buildRoomRequest(room, message); + try { + HttpResponse response = httpClient.execute(request); + sentMessages.add(SentMessages.SentMessage.responded(room, SentMessages.SentMessage.TargetType.ROOM, message, request, response)); + } catch (IOException e) { + logger.error("failed to execute hipchat api http request", e); + sentMessages.add(SentMessages.SentMessage.error(room, SentMessages.SentMessage.TargetType.ROOM, message, ExceptionsHelper.detailedMessage(e))); + } + } + } + if (message.users != null) { + for (String user : message.users) { + HttpRequest request = buildUserRequest(user, message); + try { + HttpResponse response = httpClient.execute(request); + sentMessages.add(SentMessages.SentMessage.responded(user, SentMessages.SentMessage.TargetType.USER, message, request, response)); + } catch (IOException e) { + logger.error("failed to execute hipchat api http request", e); + sentMessages.add(SentMessages.SentMessage.error(user, SentMessages.SentMessage.TargetType.USER, message, ExceptionsHelper.detailedMessage(e))); + } + } + } + return new SentMessages(name, sentMessages); + } + + public HttpRequest buildRoomRequest(String room, final HipChatMessage message) { + return server.httpRequest() + .method(HttpMethod.POST) + .scheme(Scheme.HTTPS) + .path("/v2/room/" + room + "/notification") + .setHeader("Content-Type", "application/json") + .setHeader("Authorization", "Bearer " + authToken) + .body(XContentHelper.toString(new ToXContent() { + @Override + public XContentBuilder toXContent(XContentBuilder xbuilder, Params params) throws IOException { + xbuilder.field("message", message.body); + if (message.format != null) { + xbuilder.field("message_format", message.format.value()); + } + if (message.notify != null) { + xbuilder.field("notify", message.notify); + } + if (message.color != null) { + xbuilder.field("color", String.valueOf(message.color.value())); + } + return xbuilder; + } + })) + .build(); + } + + public HttpRequest buildUserRequest(String user, final HipChatMessage message) { + return server.httpRequest() + .method(HttpMethod.POST) + .scheme(Scheme.HTTPS) + .path("/v2/user/" + user + "/message") + .setHeader("Content-Type", "application/json") + .setHeader("Authorization", "Bearer " + authToken) + .body(XContentHelper.toString(new ToXContent() { + @Override + public XContentBuilder toXContent(XContentBuilder xbuilder, Params params) throws IOException { + xbuilder.field("message", message.body); + if (message.format != null) { + xbuilder.field("message_format", message.format.value()); + } + if (message.notify != null) { + xbuilder.field("notify", message.notify); + } + return xbuilder; + } + })) + .build(); + } + + static class Defaults { + + final @Nullable String[] rooms; + final @Nullable String[] users; + final @Nullable Format format; + final @Nullable Color color; + final @Nullable Boolean notify; + + public Defaults(Settings settings) { + this.rooms = settings.getAsArray(DEFAULT_ROOM_SETTING, null); + this.users = settings.getAsArray(DEFAULT_USER_SETTING, null); + this.format = Format.resolve(settings, DEFAULT_FORMAT_SETTING, null); + this.color = Color.resolve(settings, DEFAULT_COLOR_SETTING, null); + this.notify = settings.getAsBoolean(DEFAULT_NOTIFY_SETTING, null); + } + } +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/V1Account.java b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/V1Account.java new file mode 100644 index 00000000000..c4d4358e900 --- /dev/null +++ b/watcher/src/main/java/org/elasticsearch/watcher/actions/hipchat/service/V1Account.java @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.watcher.actions.hipchat.HipChatAction; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatMessage.Color; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatMessage.Format; +import org.elasticsearch.watcher.support.http.*; +import org.elasticsearch.watcher.support.template.TemplateEngine; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * + */ +public class V1Account extends HipChatAccount { + + public static final String TYPE = "v1"; + + final Defaults defaults; + + public V1Account(String name, Settings settings, HipChatServer defaultServer, HttpClient httpClient, ESLogger logger) { + super(name, Profile.V1, settings, defaultServer, httpClient, logger); + defaults = new Defaults(settings); + } + + @Override + public String type() { + return TYPE; + } + + @Override + public void validateParsedTemplate(String watchId, String actionId, HipChatMessage.Template template) throws ElasticsearchParseException { + if (template.users != null) { + throw new ElasticsearchParseException("invalid [" + HipChatAction.TYPE + "] action for [" + watchId + "/" + actionId + "]. [" + name + "] hipchat account doesn't support user private messaging"); + } + if ((template.rooms == null || template.rooms.length == 0) && (defaults.rooms == null || defaults.rooms.length == 0)) { + throw new ElasticsearchParseException("invalid [" + HipChatAction.TYPE + "] action for [" + watchId + "/" + actionId + "]. missing required [" + HipChatMessage.Field.ROOM + "] field for [" + name + "] hipchat account"); + } + } + + @Override + public HipChatMessage render(String watchId, String actionId, TemplateEngine engine, HipChatMessage.Template template, Map model) { + String message = engine.render(template.body, model); + String[] rooms = defaults.rooms; + if (template.rooms != null) { + rooms = new String[template.rooms.length]; + for (int i = 0; i < template.rooms.length; i++) { + rooms[i] = engine.render(template.rooms[i], model); + } + } + String from = template.from != null ? template.from : defaults.from != null ? defaults.from : watchId; + Color color = Color.resolve(engine.render(template.color, model), defaults.color); + Boolean notify = template.notify != null ? template.notify : defaults.notify; + Format messageFormat = template.format != null ? template.format : defaults.format; + return new HipChatMessage(message, rooms, null, from, messageFormat, color, notify); + } + + @Override + public SentMessages send(HipChatMessage message) { + List sentMessages = new ArrayList<>(); + if (message.rooms != null) { + for (String room : message.rooms) { + HttpRequest request = buildRoomRequest(room, message); + try { + HttpResponse response = httpClient.execute(request); + sentMessages.add(SentMessages.SentMessage.responded(room, SentMessages.SentMessage.TargetType.ROOM, message, request, response)); + } catch (IOException e) { + logger.error("failed to execute hipchat api http request", e); + sentMessages.add(SentMessages.SentMessage.error(room, SentMessages.SentMessage.TargetType.ROOM, message, ExceptionsHelper.detailedMessage(e))); + } + } + } + return new SentMessages(name, sentMessages); + } + + public HttpRequest buildRoomRequest(String room, HipChatMessage message) { + HttpRequest.Builder builder = server.httpRequest(); + builder.method(HttpMethod.POST); + builder.scheme(Scheme.HTTPS); + builder.path("/v1/rooms/message"); + builder.setHeader("Content-Type", "application/x-www-form-urlencoded"); + builder.setParam("format", "json"); + builder.setParam("auth_token", authToken); + + StringBuilder body = new StringBuilder(); + body.append("room_id=").append(room); + body.append("&from=").append(HttpRequest.encodeUrl(message.from)); + body.append("&message=").append(HttpRequest.encodeUrl(message.body)); + if (message.format != null) { + body.append("&message_format=").append(message.format.value()); + } + if (message.color != null) { + body.append("&color=").append(message.color.value()); + } + if (message.notify != null) { + body.append("¬ify=").append(message.notify ? "1" : "0"); + } + builder.body(body.toString()); + return builder.build(); + } + + static class Defaults { + + final @Nullable String[] rooms; + final @Nullable String from; + final @Nullable Format format; + final @Nullable Color color; + final @Nullable Boolean notify; + + public Defaults(Settings settings) { + this.rooms = settings.getAsArray(DEFAULT_ROOM_SETTING, null); + this.from = settings.get(DEFAULT_FROM_SETTING); + this.format = Format.resolve(settings, DEFAULT_FORMAT_SETTING, null); + this.color = Color.resolve(settings, DEFAULT_COLOR_SETTING, null); + this.notify = settings.getAsBoolean(DEFAULT_NOTIFY_SETTING, null); + } + } +} diff --git a/watcher/src/main/java/org/elasticsearch/watcher/support/http/HttpRequest.java b/watcher/src/main/java/org/elasticsearch/watcher/support/http/HttpRequest.java index 398c7bded70..6539c46f52f 100644 --- a/watcher/src/main/java/org/elasticsearch/watcher/support/http/HttpRequest.java +++ b/watcher/src/main/java/org/elasticsearch/watcher/support/http/HttpRequest.java @@ -21,6 +21,9 @@ import org.elasticsearch.watcher.support.http.auth.HttpAuth; import org.elasticsearch.watcher.support.http.auth.HttpAuthRegistry; import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; import java.util.Map; public class HttpRequest implements ToXContent { @@ -101,6 +104,22 @@ public class HttpRequest implements ToXContent { return readTimeout; } + public static String encodeUrl(String text) { + try { + return URLEncoder.encode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException("failed to URL encode text [" + text + "]", e); + } + } + + public static String decodeUrl(String text) { + try { + return URLDecoder.decode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException("failed to URL decode text [" + text + "]", e); + } + } + @Override public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException { builder.startObject(); @@ -171,16 +190,28 @@ public class HttpRequest implements ToXContent { @Override public String toString() { - return "HttpRequest{" + - "auth=[" + (auth != null ? "******" : null) + - "], body=[" + body + '\'' + - "], path=[" + path + '\'' + - "], method=[" + method + - "], port=[" + port + - "], host=[" + host + '\'' + - "], connection_timeout=[" + connectionTimeout + '\'' + - "], read_timeout=[" + readTimeout + '\'' + - "]}"; + StringBuilder sb = new StringBuilder(); + sb.append("method=[").append(method).append("], "); + sb.append("scheme=[").append(scheme).append("], "); + sb.append("host=[").append(host).append("], "); + sb.append("port=[").append(port).append("], "); + sb.append("path=[").append(path).append("], "); + if (!headers.isEmpty()) { + sb.append(", headers=["); + boolean first = true; + for (Map.Entry header : headers.entrySet()) { + if (!first) { + sb.append(", "); + } + sb.append("[").append(header.getKey()).append(": ").append(header.getValue()).append("]"); + first = false; + } + sb.append("], "); + } + sb.append("connection_timeout=[").append(connectionTimeout).append("], "); + sb.append("read_timeout=[").append(readTimeout).append("], "); + sb.append("body=[").append(body).append("], "); + return sb.toString(); } public static Builder builder(String host, int port) { diff --git a/watcher/src/main/java/org/elasticsearch/watcher/support/http/HttpResponse.java b/watcher/src/main/java/org/elasticsearch/watcher/support/http/HttpResponse.java index 9beea639871..f32592615b3 100644 --- a/watcher/src/main/java/org/elasticsearch/watcher/support/http/HttpResponse.java +++ b/watcher/src/main/java/org/elasticsearch/watcher/support/http/HttpResponse.java @@ -20,6 +20,7 @@ import org.jboss.netty.handler.codec.http.HttpHeaders; import javax.annotation.Nullable; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -111,6 +112,28 @@ public class HttpResponse implements ToXContent { return result; } + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("status=[").append(status).append("]"); + if (!headers.isEmpty()) { + sb.append(", headers=["); + boolean first = true; + for (Map.Entry header : headers.entrySet()) { + if (!first) { + sb.append(", "); + } + sb.append("[").append(header.getKey()).append(": ").append(Arrays.toString(header.getValue())).append("]"); + first = false; + } + sb.append("]"); + } + if (hasContent()) { + sb.append(", body=[").append(body.toUtf8()).append("]"); + } + return sb.toString(); + } + @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder = builder.startObject().field(Field.STATUS.getPreferredName(), status); diff --git a/watcher/src/main/java/org/elasticsearch/watcher/support/xcontent/WatcherXContentUtils.java b/watcher/src/main/java/org/elasticsearch/watcher/support/xcontent/WatcherXContentUtils.java index 5899d39f5cc..e449af36278 100644 --- a/watcher/src/main/java/org/elasticsearch/watcher/support/xcontent/WatcherXContentUtils.java +++ b/watcher/src/main/java/org/elasticsearch/watcher/support/xcontent/WatcherXContentUtils.java @@ -8,11 +8,6 @@ package org.elasticsearch.watcher.support.xcontent; import org.elasticsearch.ElasticsearchParseException; import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.collect.Tuple; -import org.elasticsearch.common.compress.CompressedStreamInput; -import org.elasticsearch.common.compress.Compressor; -import org.elasticsearch.common.compress.CompressorFactory; -import org.elasticsearch.common.io.stream.StreamInput; -import org.elasticsearch.common.xcontent.XContentFactory; import org.elasticsearch.common.xcontent.XContentHelper; import org.elasticsearch.common.xcontent.XContentParser; import org.elasticsearch.common.xcontent.XContentType; @@ -38,6 +33,29 @@ public class WatcherXContentUtils { } } + public static String[] readStringArray(XContentParser parser, boolean allowNull) throws IOException { + if (parser.currentToken() == XContentParser.Token.VALUE_NULL) { + if (allowNull) { + return null; + } + throw new ElasticsearchParseException("could not parse [{}] field. expected a string array but found null value instead", parser.currentName()); + } + if (parser.currentToken() != XContentParser.Token.START_ARRAY) { + throw new ElasticsearchParseException("could not parse [{}] field. expected a string array but found [{}] value instead", parser.currentName(), parser.currentToken()); + } + + List list = new ArrayList<>(); + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_ARRAY) { + if (token == XContentParser.Token.VALUE_STRING) { + list.add(parser.text()); + } else { + throw new ElasticsearchParseException("could not parse [{}] field. expected a string array but one of the value in the array is [{}]", parser.currentName(), token); + } + } + return list.toArray(new String[list.size()]); + } + // TODO open this up in core public static List readList(XContentParser parser, XContentParser.Token token) throws IOException { List list = new ArrayList<>(); diff --git a/watcher/src/main/resources/watch_history.json b/watcher/src/main/resources/watch_history.json index ed089698610..b3484f4164a 100644 --- a/watcher/src/main/resources/watch_history.json +++ b/watcher/src/main/resources/watch_history.json @@ -321,6 +321,69 @@ } } } + }, + "hipchat" : { + "type": "object", + "dynamic": true, + "properties": { + "account": { + "type": "string", + "index": "not_analyzed" + }, + "sent_messages": { + "type": "nested", + "include_in_parent": true, + "dynamic": true, + "properties": { + "status": { + "type": "string", + "index": "not_analyzed" + }, + "reason": { + "type": "string" + }, + "request" : { + "type" : "object", + "enabled" : false + }, + "response" : { + "type" : "object", + "enabled" : false + }, + "room" : { + "type": "string", + "index": "not_analyzed" + }, + "user" : { + "type": "string", + "index": "not_analyzed" + }, + "message" : { + "type" : "object", + "dynamic" : true, + "properties" : { + "message_format" : { + "type" : "string", + "index" : "not_analyzed" + }, + "color" : { + "type" : "string", + "index" : "not_analyzed" + }, + "notify" : { + "type" : "boolean" + }, + "message" : { + "type" : "string" + }, + "from" : { + "type" : "string" + } + } + } + } + } + } } } } diff --git a/watcher/src/test/java/org/elasticsearch/watcher/WatcherF.java b/watcher/src/test/java/org/elasticsearch/watcher/WatcherF.java index dd31badaaac..e95d230a894 100644 --- a/watcher/src/test/java/org/elasticsearch/watcher/WatcherF.java +++ b/watcher/src/test/java/org/elasticsearch/watcher/WatcherF.java @@ -24,6 +24,22 @@ public class WatcherF { System.setProperty("es.shield.enabled", "false"); System.setProperty("es.security.manager.enabled", "false"); System.setProperty("es.plugins.load_classpath_plugins", "false"); + + // this is for the `test-watcher-integration` group level integration in HipChat + System.setProperty("es.watcher.actions.hipchat.service.account.integration.profile", "integration"); + System.setProperty("es.watcher.actions.hipchat.service.account.integration.auth_token", "huuS9v7ccuOy3ZBWWWr1vt8Lqu3sQnLUE81nrLZU"); + System.setProperty("es.watcher.actions.hipchat.service.account.integration.room", "test-watcher"); + + // this is for the Watcher Test account in HipChat + System.setProperty("es.watcher.actions.hipchat.service.account.user.profile", "user"); + System.setProperty("es.watcher.actions.hipchat.service.account.user.auth_token", "FYVx16oDH78ZW9r13wtXbcszyoyA7oX5tiMWg9X0"); + + // this is for the `test-watcher-v1` notification token + System.setProperty("es.watcher.actions.hipchat.service.account.v1.profile", "v1"); + System.setProperty("es.watcher.actions.hipchat.service.account.v1.auth_token", "a734baf62df618b96dda55b323fc30"); + + + System.setProperty("es.plugin.types", WatcherPlugin.class.getName() + "," + LicensePlugin.class.getName()); System.setProperty("es.cluster.name", WatcherF.class.getSimpleName()); diff --git a/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionFactoryTests.java b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionFactoryTests.java new file mode 100644 index 00000000000..788797d2561 --- /dev/null +++ b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionFactoryTests.java @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatAccount; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatMessage; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatService; +import org.elasticsearch.watcher.support.template.Template; +import org.elasticsearch.watcher.support.template.TemplateEngine; +import org.junit.Before; +import org.junit.Test; + +import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.elasticsearch.watcher.actions.ActionBuilders.hipchatAction; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.Mockito.*; + +/** + * + */ +public class HipChatActionFactoryTests extends ESTestCase { + + private HipChatActionFactory factory; + private HipChatService hipchatService; + + @Before + public void init() throws Exception { + hipchatService = mock(HipChatService.class); + factory = new HipChatActionFactory(Settings.EMPTY, mock(TemplateEngine.class), hipchatService); + } + + @Test + public void testParseAction() throws Exception { + + HipChatAccount account = mock(HipChatAccount.class); + when(hipchatService.getAccount("_account1")).thenReturn(account); + + HipChatAction action = hipchatAction("_account1", "_body").build(); + XContentBuilder jsonBuilder = jsonBuilder().value(action); + XContentParser parser = JsonXContent.jsonXContent.createParser(jsonBuilder.bytes()); + parser.nextToken(); + + HipChatAction parsedAction = factory.parseAction("_w1", "_a1", parser); + assertThat(parsedAction, is(action)); + + verify(account, times(1)).validateParsedTemplate("_w1", "_a1", action.message); + } + + @Test(expected = ElasticsearchParseException.class) + public void testtestParseAction_UnknownAccount() throws Exception { + + when(hipchatService.getAccount("_unknown")).thenReturn(null); + + HipChatAction action = hipchatAction("_unknown", "_body").build(); + XContentBuilder jsonBuilder = jsonBuilder().value(action); + XContentParser parser = JsonXContent.jsonXContent.createParser(jsonBuilder.bytes()); + parser.nextToken(); + factory.parseAction("_w1", "_a1", parser); + } + + @Test + public void testParser() throws Exception { + + XContentBuilder builder = jsonBuilder().startObject(); + + String accountName = randomAsciiOfLength(10); + builder.field("account", accountName); + builder.startObject("message"); + + Template body = Template.inline("_body").build(); + builder.field("body", body); + + Template[] rooms = null; + if (randomBoolean()) { + Template r1 = Template.inline("_r1").build(); + Template r2 = Template.inline("_r2").build(); + rooms = new Template[] { r1, r2 }; + builder.array("room", r1, r2); + } + Template[] users = null; + if (randomBoolean()) { + Template u1 = Template.inline("_u1").build(); + Template u2 = Template.inline("_u2").build(); + users = new Template[] { u1, u2 }; + builder.array("user", u1, u2); + } + String from = null; + if (randomBoolean()) { + from = randomAsciiOfLength(10); + builder.field("from", from); + } + HipChatMessage.Format format = null; + if (randomBoolean()) { + format = randomFrom(HipChatMessage.Format.values()); + builder.field("format", format.value()); + } + Template color = null; + if (randomBoolean()) { + color = Template.inline(randomFrom(HipChatMessage.Color.values()).value()).build(); + builder.field("color", color); + } + Boolean notify = null; + if (randomBoolean()) { + notify = randomBoolean(); + builder.field("notify", notify); + } + + builder.endObject(); + builder.endObject(); + + BytesReference bytes = builder.bytes(); + logger.info("hipchat action json [{}]", bytes.toUtf8()); + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + + HipChatAction action = HipChatAction.parse("_watch", "_action", parser); + + assertThat(action, notNullValue()); + assertThat(action.account, is(accountName)); + assertThat(action.message, notNullValue()); + assertThat(action.message, is(new HipChatMessage.Template(body, rooms, users, from, format, color, notify))); + } + + + @Test + public void testParser_SelfGenerated() throws Exception { + + String accountName = randomAsciiOfLength(10); + Template body = Template.inline("_body").build(); + HipChatMessage.Template.Builder templateBuilder = new HipChatMessage.Template.Builder(body); + + XContentBuilder builder = jsonBuilder().startObject(); + builder.field("account", accountName); + builder.startObject("message"); + builder.field("body", body); + + if (randomBoolean()) { + Template r1 = Template.inline("_r1").build(); + Template r2 = Template.inline("_r2").build(); + templateBuilder.addRooms(r1, r2); + builder.array("room", r1, r2); + } + if (randomBoolean()) { + Template u1 = Template.inline("_u1").build(); + Template u2 = Template.inline("_u2").build(); + templateBuilder.addUsers(u1, u2); + builder.array("user", u1, u2); + } + if (randomBoolean()) { + String from = randomAsciiOfLength(10); + templateBuilder.setFrom(from); + builder.field("from", from); + } + if (randomBoolean()) { + HipChatMessage.Format format = randomFrom(HipChatMessage.Format.values()); + templateBuilder.setFormat(format); + builder.field("format", format.value()); + } + if (randomBoolean()) { + Template color = Template.inline(randomFrom(HipChatMessage.Color.values()).value()).build(); + templateBuilder.setColor(color); + builder.field("color", color); + } + if (randomBoolean()) { + boolean notify = randomBoolean(); + templateBuilder.setNotify(notify); + builder.field("notify", notify); + } + + builder.endObject(); + builder.endObject(); + + HipChatMessage.Template template = templateBuilder.build(); + + HipChatAction action = new HipChatAction(accountName, template); + + XContentBuilder jsonBuilder = jsonBuilder(); + action.toXContent(jsonBuilder, ToXContent.EMPTY_PARAMS); + BytesReference bytes = builder.bytes(); + logger.info(bytes.toUtf8()); + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + + HipChatAction parsedAction = HipChatAction.parse("_watch", "_action", parser); + + assertThat(parsedAction, notNullValue()); + assertThat(parsedAction, is(action)); + } + + @Test(expected = ElasticsearchParseException.class) + public void testParser_Invalid() throws Exception { + XContentBuilder builder = jsonBuilder().startObject().field("unknown_field", "value"); + XContentParser parser = JsonXContent.jsonXContent.createParser(builder.bytes()); + parser.nextToken(); + HipChatAction.parse("_watch", "_action", parser); + } +} diff --git a/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionTests.java b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionTests.java new file mode 100644 index 00000000000..a28d1253023 --- /dev/null +++ b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/HipChatActionTests.java @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat; + +import com.google.common.collect.ImmutableMap; +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.collect.MapBuilder; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.watcher.actions.Action; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatAccount; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatMessage; +import org.elasticsearch.watcher.actions.hipchat.service.HipChatService; +import org.elasticsearch.watcher.actions.hipchat.service.SentMessages; +import org.elasticsearch.watcher.execution.WatchExecutionContext; +import org.elasticsearch.watcher.execution.Wid; +import org.elasticsearch.watcher.support.http.HttpRequest; +import org.elasticsearch.watcher.support.http.HttpResponse; +import org.elasticsearch.watcher.support.template.Template; +import org.elasticsearch.watcher.support.template.TemplateEngine; +import org.elasticsearch.watcher.watch.Payload; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.elasticsearch.watcher.test.WatcherTestUtils.mockExecutionContextBuilder; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * + */ +public class HipChatActionTests extends ESTestCase { + + private HipChatService service; + + @Before + public void init() throws Exception { + service = mock(HipChatService.class); + } + + @Test + public void testExecute() throws Exception { + final String accountName = "account1"; + + TemplateEngine templateEngine = mock(TemplateEngine.class); + + Template body = Template.inline("_body").build(); + HipChatMessage.Template.Builder messageBuilder = new HipChatMessage.Template.Builder(body); + + HipChatMessage.Template messageTemplate = messageBuilder.build(); + + HipChatAction action = new HipChatAction(accountName, messageTemplate); + ExecutableHipChatAction executable = new ExecutableHipChatAction(action, logger, service, templateEngine); + + Map data = new HashMap<>(); + Payload payload = new Payload.Simple(data); + + Map metadata = MapBuilder.newMapBuilder().put("_key", "_val").map(); + + DateTime now = DateTime.now(DateTimeZone.UTC); + + Wid wid = new Wid(randomAsciiOfLength(5), randomLong(), now); + WatchExecutionContext ctx = mockExecutionContextBuilder(wid.watchId()) + .wid(wid) + .payload(payload) + .time(wid.watchId(), now) + .metadata(metadata) + .buildMock(); + + Map expectedModel = ImmutableMap.builder() + .put("ctx", ImmutableMap.builder() + .put("id", ctx.id().value()) + .put("watch_id", wid.watchId()) + .put("payload", data) + .put("metadata", metadata) + .put("execution_time", now) + .put("trigger", ImmutableMap.builder() + .put("triggered_time", now) + .put("scheduled_time", now) + .build()) + .put("vars", Collections.emptyMap()) + .build()) + .build(); + + if (body != null) { + when(templateEngine.render(body, expectedModel)).thenReturn(body.getTemplate()); + } + + String[] rooms = new String[] { "_r1" }; + HipChatMessage message = new HipChatMessage(body.getTemplate(), rooms, null, null, null, null, null); + HipChatAccount account = mock(HipChatAccount.class); + when(account.render(wid.watchId(), "_id", templateEngine, messageTemplate, expectedModel)).thenReturn(message); + HttpResponse response = mock(HttpResponse.class); + when(response.status()).thenReturn(200); + HttpRequest request = mock(HttpRequest.class); + SentMessages sentMessages = new SentMessages(accountName, Arrays.asList( + SentMessages.SentMessage.responded("_r1", SentMessages.SentMessage.TargetType.ROOM, message, request, response) + )); + when(account.send(message)).thenReturn(sentMessages); + when(service.getAccount(accountName)).thenReturn(account); + + Action.Result result = executable.execute("_id", ctx, payload); + + assertThat(result, notNullValue()); + assertThat(result, instanceOf(HipChatAction.Result.Executed.class)); + assertThat(result.status(), equalTo(Action.Result.Status.SUCCESS)); + assertThat(((HipChatAction.Result.Executed) result).sentMessages(), sameInstance(sentMessages)); + } + + @Test + public void testParser() throws Exception { + + XContentBuilder builder = jsonBuilder().startObject(); + + String accountName = randomAsciiOfLength(10); + builder.field("account", accountName); + builder.startObject("message"); + + Template body = Template.inline("_body").build(); + builder.field("body", body); + + Template[] rooms = null; + if (randomBoolean()) { + Template r1 = Template.inline("_r1").build(); + Template r2 = Template.inline("_r2").build(); + rooms = new Template[] { r1, r2 }; + builder.array("room", r1, r2); + } + Template[] users = null; + if (randomBoolean()) { + Template u1 = Template.inline("_u1").build(); + Template u2 = Template.inline("_u2").build(); + users = new Template[] { u1, u2 }; + builder.array("user", u1, u2); + } + String from = null; + if (randomBoolean()) { + from = randomAsciiOfLength(10); + builder.field("from", from); + } + HipChatMessage.Format format = null; + if (randomBoolean()) { + format = randomFrom(HipChatMessage.Format.values()); + builder.field("format", format.value()); + } + Template color = null; + if (randomBoolean()) { + color = Template.inline(randomFrom(HipChatMessage.Color.values()).value()).build(); + builder.field("color", color); + } + Boolean notify = null; + if (randomBoolean()) { + notify = randomBoolean(); + builder.field("notify", notify); + } + + builder.endObject(); + builder.endObject(); + + BytesReference bytes = builder.bytes(); + logger.info("hipchat action json [{}]", bytes.toUtf8()); + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + + HipChatAction action = HipChatAction.parse("_watch", "_action", parser); + + assertThat(action, notNullValue()); + assertThat(action.account, is(accountName)); + assertThat(action.message, notNullValue()); + assertThat(action.message, is(new HipChatMessage.Template(body, rooms, users, from, format, color, notify))); + } + + + @Test + public void testParser_SelfGenerated() throws Exception { + + String accountName = randomAsciiOfLength(10); + Template body = Template.inline("_body").build(); + HipChatMessage.Template.Builder templateBuilder = new HipChatMessage.Template.Builder(body); + + XContentBuilder builder = jsonBuilder().startObject(); + builder.field("account", accountName); + builder.startObject("message"); + builder.field("body", body); + + if (randomBoolean()) { + Template r1 = Template.inline("_r1").build(); + Template r2 = Template.inline("_r2").build(); + templateBuilder.addRooms(r1, r2); + builder.array("room", r1, r2); + } + if (randomBoolean()) { + Template u1 = Template.inline("_u1").build(); + Template u2 = Template.inline("_u2").build(); + templateBuilder.addUsers(u1, u2); + builder.array("user", u1, u2); + } + if (randomBoolean()) { + String from = randomAsciiOfLength(10); + templateBuilder.setFrom(from); + builder.field("from", from); + } + if (randomBoolean()) { + HipChatMessage.Format format = randomFrom(HipChatMessage.Format.values()); + templateBuilder.setFormat(format); + builder.field("format", format.value()); + } + if (randomBoolean()) { + Template color = Template.inline(randomFrom(HipChatMessage.Color.values()).value()).build(); + templateBuilder.setColor(color); + builder.field("color", color); + } + if (randomBoolean()) { + boolean notify = randomBoolean(); + templateBuilder.setNotify(notify); + builder.field("notify", notify); + } + + builder.endObject(); + builder.endObject(); + + HipChatMessage.Template template = templateBuilder.build(); + + HipChatAction action = new HipChatAction(accountName, template); + + XContentBuilder jsonBuilder = jsonBuilder(); + action.toXContent(jsonBuilder, ToXContent.EMPTY_PARAMS); + BytesReference bytes = builder.bytes(); + logger.info(bytes.toUtf8()); + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + + HipChatAction parsedAction = HipChatAction.parse("_watch", "_action", parser); + + assertThat(parsedAction, notNullValue()); + assertThat(parsedAction, is(action)); + } + + @Test(expected = ElasticsearchParseException.class) + public void testParser_Invalid() throws Exception { + XContentBuilder builder = jsonBuilder().startObject().field("unknown_field", "value"); + XContentParser parser = JsonXContent.jsonXContent.createParser(builder.bytes()); + parser.nextToken(); + HipChatAction.parse("_watch", "_action", parser); + } +} diff --git a/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccountsTests.java b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccountsTests.java new file mode 100644 index 00000000000..9534e8be11a --- /dev/null +++ b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatAccountsTests.java @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.watcher.support.http.HttpClient; +import org.junit.Before; +import org.junit.Test; + +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.mock; + +/** + * + */ +public class HipChatAccountsTests extends ESTestCase { + + private HttpClient httpClient; + + @Before + public void init() throws Exception { + httpClient = mock(HttpClient.class); + } + + @Test + public void testSingleAccount() throws Exception { + Settings.Builder builder = Settings.builder() + .put("default_account", "account1"); + addAccountSettings("account1", builder); + + HipChatAccounts accounts = new HipChatAccounts(builder.build(), httpClient, logger); + HipChatAccount account = accounts.account("account1"); + assertThat(account, notNullValue()); + assertThat(account.name, equalTo("account1")); + account = accounts.account(null); // falling back on the default + assertThat(account, notNullValue()); + assertThat(account.name, equalTo("account1")); + } + + @Test + public void testSingleAccount_NoExplicitDefault() throws Exception { + Settings.Builder builder = Settings.builder(); + addAccountSettings("account1", builder); + + HipChatAccounts accounts = new HipChatAccounts(builder.build(), httpClient, logger); + HipChatAccount account = accounts.account("account1"); + assertThat(account, notNullValue()); + assertThat(account.name, equalTo("account1")); + account = accounts.account(null); // falling back on the default + assertThat(account, notNullValue()); + assertThat(account.name, equalTo("account1")); + } + + @Test + public void testMultipleAccounts() throws Exception { + Settings.Builder builder = Settings.builder() + .put("default_account", "account1"); + addAccountSettings("account1", builder); + addAccountSettings("account2", builder); + + HipChatAccounts accounts = new HipChatAccounts(builder.build(), httpClient, logger); + HipChatAccount account = accounts.account("account1"); + assertThat(account, notNullValue()); + assertThat(account.name, equalTo("account1")); + account = accounts.account("account2"); + assertThat(account, notNullValue()); + assertThat(account.name, equalTo("account2")); + account = accounts.account(null); // falling back on the default + assertThat(account, notNullValue()); + assertThat(account.name, equalTo("account1")); + } + + @Test + public void testMultipleAccounts_NoExplicitDefault() throws Exception { + Settings.Builder builder = Settings.builder() + .put("default_account", "account1"); + addAccountSettings("account1", builder); + addAccountSettings("account2", builder); + + HipChatAccounts accounts = new HipChatAccounts(builder.build(), httpClient, logger); + HipChatAccount account = accounts.account("account1"); + assertThat(account, notNullValue()); + assertThat(account.name, equalTo("account1")); + account = accounts.account("account2"); + assertThat(account, notNullValue()); + assertThat(account.name, equalTo("account2")); + account = accounts.account(null); + assertThat(account, notNullValue()); + assertThat(account.name, isOneOf("account1", "account2")); + } + + @Test(expected = SettingsException.class) + public void testMultipleAccounts_UnknownDefault() throws Exception { + Settings.Builder builder = Settings.builder() + .put("default_account", "unknown"); + addAccountSettings("account1", builder); + addAccountSettings("account2", builder); + new HipChatAccounts(builder.build(), httpClient, logger); + } + + @Test(expected = IllegalStateException.class) + public void testNoAccount() throws Exception { + Settings.Builder builder = Settings.builder(); + HipChatAccounts accounts = new HipChatAccounts(builder.build(), httpClient, logger); + accounts.account(null); + fail("no accounts are configured so trying to get the default account should throw an IllegalStateException"); + } + + @Test(expected = SettingsException.class) + public void testNoAccount_WithDefaultAccount() throws Exception { + Settings.Builder builder = Settings.builder() + .put("default_account", "unknown"); + new HipChatAccounts(builder.build(), httpClient, logger); + } + + private void addAccountSettings(String name, Settings.Builder builder) { + HipChatAccount.Profile profile = randomFrom(HipChatAccount.Profile.values()); + builder.put("account." + name + ".profile", profile.value()); + builder.put("account." + name + ".auth_token", randomAsciiOfLength(50)); + if (profile == HipChatAccount.Profile.INTEGRATION) { + builder.put("account." + name + ".room", randomAsciiOfLength(10)); + } + } +} diff --git a/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatMessageTests.java b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatMessageTests.java new file mode 100644 index 00000000000..95af423c7c3 --- /dev/null +++ b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatMessageTests.java @@ -0,0 +1,291 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.watcher.support.template.Template; +import org.elasticsearch.watcher.support.xcontent.WatcherXContentUtils; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.Matchers.*; + +/** + * + */ +public class HipChatMessageTests extends ESTestCase { + + @Test + public void testToXContent() throws Exception { + String message = randomAsciiOfLength(10); + String[] rooms = generateRandomStringArray(3, 10, true); + String[] users = generateRandomStringArray(3, 10, true); + String from = randomBoolean() ? null : randomAsciiOfLength(10); + HipChatMessage.Format format = rarely() ? null : randomFrom(HipChatMessage.Format.values()); + HipChatMessage.Color color = rarely() ? null : randomFrom(HipChatMessage.Color.values()); + Boolean notify = rarely() ? null : randomBoolean(); + HipChatMessage msg = new HipChatMessage(message, rooms, users, from, format, color, notify); + + XContentBuilder builder = jsonBuilder(); + boolean includeTarget = randomBoolean(); + if (includeTarget && randomBoolean()) { + msg.toXContent(builder, ToXContent.EMPTY_PARAMS); + } else { + msg.toXContent(builder, ToXContent.EMPTY_PARAMS, includeTarget); + } + BytesReference bytes = builder.bytes(); + + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + + assertThat(parser.currentToken(), is(XContentParser.Token.START_OBJECT)); + + message = null; + rooms = null; + users = null; + from = null; + format = null; + color = null; + notify = null; + XContentParser.Token token = null; + String currentFieldName = null; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if ("body".equals(currentFieldName)) { + message = parser.text(); + } else if ("room".equals(currentFieldName)) { + rooms = WatcherXContentUtils.readStringArray(parser, false); + } else if ("user".equals(currentFieldName)) { + users = WatcherXContentUtils.readStringArray(parser, false); + } else if ("from".equals(currentFieldName)) { + from = parser.text(); + } else if ("format".equals(currentFieldName)) { + format = HipChatMessage.Format.parse(parser); + } else if ("color".equals(currentFieldName)) { + color = HipChatMessage.Color.parse(parser); + } else if ("notify".equals(currentFieldName)) { + notify = parser.booleanValue(); + } else { + fail("unexpected xconent field [" + currentFieldName + "] in hipchat message"); + } + } + + assertThat(message, notNullValue()); + assertThat(message, is(msg.body)); + if (includeTarget) { + if (msg.rooms == null || msg.rooms.length == 0) { + assertThat(rooms, nullValue()); + } else { + assertThat(rooms, arrayContaining(msg.rooms)); + } + if (msg.users == null || msg.users.length == 0) { + assertThat(users, nullValue()); + } else { + assertThat(users, arrayContaining(msg.users)); + } + } + assertThat(from, is(msg.from)); + assertThat(format, is(msg.format)); + assertThat(color, is(msg.color)); + assertThat(notify, is(msg.notify)); + } + + @Test + public void testEquals() throws Exception { + String message = randomAsciiOfLength(10); + String[] rooms = generateRandomStringArray(3, 10, true); + String[] users = generateRandomStringArray(3, 10, true); + String from = randomBoolean() ? null : randomAsciiOfLength(10); + HipChatMessage.Format format = rarely() ? null : randomFrom(HipChatMessage.Format.values()); + HipChatMessage.Color color = rarely() ? null : randomFrom(HipChatMessage.Color.values()); + Boolean notify = rarely() ? null : randomBoolean(); + HipChatMessage msg1 = new HipChatMessage(message, rooms, users, from, format, color, notify); + + boolean equals = randomBoolean(); + if (!equals) { + equals = true; + if (rarely()) { + equals = false; + message = "another message"; + } + if (rarely()) { + equals = false; + rooms = rooms == null ? new String[] { "roomX" } : randomBoolean() ? null : new String[] { "roomX" , "roomY"}; + } + if (rarely()) { + equals = false; + users = users == null ? new String[] { "userX" } : randomBoolean() ? null : new String[] { "userX", "userY" }; + } + if (rarely()) { + equals = false; + from = from == null ? "fromX" : randomBoolean() ? null : "fromY"; + } + if (rarely()) { + equals = false; + format = format == null ? + randomFrom(HipChatMessage.Format.values()) : + randomBoolean() ? + null : + randomFrom(HipChatMessage.Format.values(), format); + } + if (rarely()) { + equals = false; + color = color == null ? + randomFrom(HipChatMessage.Color.values()) : + randomBoolean() ? + null : + randomFrom(HipChatMessage.Color.values(), color); + } + if (rarely()) { + equals = false; + notify = notify == null ? (Boolean) randomBoolean() : randomBoolean() ? null : (Boolean) randomBoolean(); + } + } + + HipChatMessage msg2 = new HipChatMessage(message, rooms, users, from, format, color, notify); + assertThat(msg1.equals(msg2), is(equals)); + } + + @Test + public void testTemplate_Parse() throws Exception { + XContentBuilder jsonBuilder = jsonBuilder(); + jsonBuilder.startObject(); + + Template body = Template.inline(randomAsciiOfLength(200)).build(); + jsonBuilder.field("body", body, ToXContent.EMPTY_PARAMS); + Template[] rooms = null; + if (randomBoolean()) { + jsonBuilder.startArray("room"); + rooms = new Template[randomIntBetween(1, 3)]; + for (int i = 0; i < rooms.length; i++) { + rooms[i] = Template.inline(randomAsciiOfLength(10)).build(); + rooms[i].toXContent(jsonBuilder, ToXContent.EMPTY_PARAMS); + } + jsonBuilder.endArray(); + } + Template[] users = null; + if (randomBoolean()) { + jsonBuilder.startArray("user"); + users = new Template[randomIntBetween(1, 3)]; + for (int i = 0; i < users.length; i++) { + users[i] = Template.inline(randomAsciiOfLength(10)).build(); + users[i].toXContent(jsonBuilder, ToXContent.EMPTY_PARAMS); + } + jsonBuilder.endArray(); + } + String from = null; + if (randomBoolean()) { + from = randomAsciiOfLength(10); + jsonBuilder.field("from", from); + } + Template color = null; + if (randomBoolean()) { + color = Template.inline(randomAsciiOfLength(10)).build(); + jsonBuilder.field("color", color, ToXContent.EMPTY_PARAMS); + } + HipChatMessage.Format format = null; + if (randomBoolean()) { + format = randomFrom(HipChatMessage.Format.values()); + jsonBuilder.field("format", format, ToXContent.EMPTY_PARAMS); + } + Boolean notify = null; + if (randomBoolean()) { + notify = randomBoolean(); + jsonBuilder.field("notify", notify); + } + + BytesReference bytes = jsonBuilder.bytes(); + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + + HipChatMessage.Template template = HipChatMessage.Template.parse(parser); + + assertThat(template, notNullValue()); + assertThat(template.body, is(body)); + if (rooms == null) { + assertThat(template.rooms, nullValue()); + } else { + assertThat(template.rooms, arrayContaining(rooms)); + } + if (users == null) { + assertThat(template.users, nullValue()); + } else { + assertThat(template.users, arrayContaining(users)); + } + assertThat(template.from, is(from)); + assertThat(template.color, is(color)); + assertThat(template.format, is(format)); + assertThat(template.notify, is(notify)); + } + + @Test + public void testTemplate_ParseSelfGenerated() throws Exception { + Template body = Template.inline(randomAsciiOfLength(10)).build(); + HipChatMessage.Template.Builder templateBuilder = new HipChatMessage.Template.Builder(body); + + if (randomBoolean()) { + int count = randomIntBetween(1, 3); + for (int i = 0; i < count; i++) { + templateBuilder.addRooms(Template.inline(randomAsciiOfLength(10)).build()); + } + } + if (randomBoolean()) { + int count = randomIntBetween(1, 3); + for (int i = 0; i < count; i++) { + templateBuilder.addUsers(Template.inline(randomAsciiOfLength(10)).build()); + } + } + if (randomBoolean()) { + templateBuilder.setFrom(randomAsciiOfLength(10)); + } + if (randomBoolean()) { + templateBuilder.setColor(Template.inline(randomAsciiOfLength(5)).build()); + } + if (randomBoolean()) { + templateBuilder.setFormat(randomFrom(HipChatMessage.Format.values())); + } + if (randomBoolean()) { + templateBuilder.setNotify(randomBoolean()); + } + HipChatMessage.Template template = templateBuilder.build(); + + XContentBuilder jsonBuilder = jsonBuilder(); + template.toXContent(jsonBuilder, ToXContent.EMPTY_PARAMS); + BytesReference bytes = jsonBuilder.bytes(); + + XContentParser parser = JsonXContent.jsonXContent.createParser(bytes); + parser.nextToken(); + + HipChatMessage.Template parsed = HipChatMessage.Template.parse(parser); + + assertThat(parsed, equalTo(template)); + + } + + static E randomFrom(E[] values, E... exclude) { + List excludes = Arrays.asList(exclude); + List includes = new ArrayList<>(); + for (E value : values) { + if (!excludes.contains(value)) { + includes.add(value); + } + } + return randomFrom(includes); + } + +} diff --git a/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatServiceIT.java b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatServiceIT.java new file mode 100644 index 00000000000..37b02ae8079 --- /dev/null +++ b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/HipChatServiceIT.java @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.action.search.SearchResponse; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.test.junit.annotations.Network; +import org.elasticsearch.watcher.actions.hipchat.HipChatAction; +import org.elasticsearch.watcher.test.AbstractWatcherIntegrationTests; +import org.elasticsearch.watcher.transport.actions.put.PutWatchResponse; +import org.junit.Before; +import org.junit.Test; + +import static org.elasticsearch.index.query.QueryBuilders.boolQuery; +import static org.elasticsearch.index.query.QueryBuilders.termQuery; +import static org.elasticsearch.search.builder.SearchSourceBuilder.searchSource; +import static org.elasticsearch.watcher.actions.ActionBuilders.hipchatAction; +import static org.elasticsearch.watcher.client.WatchSourceBuilders.watchBuilder; +import static org.elasticsearch.watcher.condition.ConditionBuilders.alwaysCondition; +import static org.elasticsearch.watcher.input.InputBuilders.simpleInput; +import static org.elasticsearch.watcher.trigger.TriggerBuilders.schedule; +import static org.elasticsearch.watcher.trigger.schedule.Schedules.interval; +import static org.hamcrest.Matchers.*; + +/** + * + */ +@Network +public class HipChatServiceIT extends AbstractWatcherIntegrationTests { + + private HipChatService service; + + @Override + protected boolean timeWarped() { + return true; + } + + @Override + protected boolean enableShield() { + return false; + } + + @Override + protected Settings nodeSettings(int nodeOrdinal) { + return Settings.builder() + .put(super.nodeSettings(nodeOrdinal)) + + // this is for the `test-watcher-integration` group level integration in HipChat + .put("watcher.actions.hipchat.service.account.integration_account.profile", "integration") + .put("watcher.actions.hipchat.service.account.integration_account.auth_token", "huuS9v7ccuOy3ZBWWWr1vt8Lqu3sQnLUE81nrLZU") + .put("watcher.actions.hipchat.service.account.integration_account.room", "test-watcher") + + // this is for the Watcher Test account in HipChat + .put("watcher.actions.hipchat.service.account.user_account.profile", "user") + .put("watcher.actions.hipchat.service.account.user_account.auth_token", "FYVx16oDH78ZW9r13wtXbcszyoyA7oX5tiMWg9X0") + + // this is for the `test-watcher-v1` notification token + .put("watcher.actions.hipchat.service.account.v1_account.profile", "v1") + .put("watcher.actions.hipchat.service.account.v1_account.auth_token", "a734baf62df618b96dda55b323fc30") + .build(); + } + + @Before + public void init() throws Exception { + service = getInstanceFromMaster(HipChatService.class); + } + + @Test + public void testSendMessage_V1Account() throws Exception { + HipChatMessage hipChatMessage = new HipChatMessage( + "/code HipChatServiceIT#testSendMessage_V1Account", + new String[] { "test-watcher", "test-watcher-2" }, + null, // users are unsupported in v1 + "watcher-tests", + HipChatMessage.Format.TEXT, + randomFrom(HipChatMessage.Color.values()), + true); + + HipChatAccount account = service.getAccount("v1_account"); + assertThat(account, notNullValue()); + SentMessages messages = account.send(hipChatMessage); + assertThat(messages.count(), is(2)); + for (SentMessages.SentMessage message : messages) { + assertThat(message.successful(), is(true)); + assertThat(message.request, notNullValue()); + assertThat(message.response, notNullValue()); + assertThat(message.response.status(), lessThan(300)); + } + } + + @Test + public void testSendMessage_IntegrationAccount() throws Exception { + HipChatMessage hipChatMessage = new HipChatMessage( + "/code HipChatServiceIT#testSendMessage_IntegrationAccount", + null, // custom rooms are unsupported by integration profiles + null, // users are unsupported by integration profiles + null, // custom "from" is not supported by integration profiles + HipChatMessage.Format.TEXT, + randomFrom(HipChatMessage.Color.values()), + true); + + HipChatAccount account = service.getAccount("integration_account"); + assertThat(account, notNullValue()); + SentMessages messages = account.send(hipChatMessage); + assertThat(messages.count(), is(1)); + for (SentMessages.SentMessage message : messages) { + assertThat(message.successful(), is(true)); + assertThat(message.request, notNullValue()); + assertThat(message.response, notNullValue()); + assertThat(message.response.status(), lessThan(300)); + } + } + + @Test + public void testSendMessage_UserAccount() throws Exception { + HipChatMessage hipChatMessage = new HipChatMessage( + "/code HipChatServiceIT#testSendMessage_UserAccount", + new String[] { "test-watcher", "test-watcher-2" }, + new String[] { "watcher@elastic.co" }, + null, // custom "from" is not supported by integration profiles + HipChatMessage.Format.TEXT, + randomFrom(HipChatMessage.Color.values()), + false); + + HipChatAccount account = service.getAccount("user_account"); + assertThat(account, notNullValue()); + SentMessages messages = account.send(hipChatMessage); + assertThat(messages.count(), is(3)); + for (SentMessages.SentMessage message : messages) { + assertThat(message.successful(), is(true)); + assertThat(message.request, notNullValue()); + assertThat(message.response, notNullValue()); + assertThat(message.response.status(), lessThan(300)); + } + } + + @Test + public void testWatchWithHipChatAction() throws Exception { + + + HipChatAccount.Profile profile = randomFrom(HipChatAccount.Profile.values()); + String account; + HipChatAction.Builder actionBuilder; + switch (profile) { + case USER: + account = "user_account"; + actionBuilder = hipchatAction(account, "/code {{ctx.payload.ref}}") + .addRooms("test-watcher", "test-watcher-2") + .addUsers("watcher@elastic.co") + .setFormat(HipChatMessage.Format.TEXT) + .setColor(randomFrom(HipChatMessage.Color.values())) + .setNotify(false); + break; + + case INTEGRATION: + account = "integration_account"; + actionBuilder = hipchatAction(account, "/code {{ctx.payload.ref}}") + .setFormat(HipChatMessage.Format.TEXT) + .setColor(randomFrom(HipChatMessage.Color.values())) + .setNotify(false); + break; + + default: + assertThat(profile, is(HipChatAccount.Profile.V1)); + account = "v1_account"; + actionBuilder = hipchatAction(account, "/code {{ctx.payload.ref}}") + .addRooms("test-watcher", "test-watcher-2") + .setFrom("watcher-test") + .setFormat(HipChatMessage.Format.TEXT) + .setColor(randomFrom(HipChatMessage.Color.values())) + .setNotify(false); + } + + PutWatchResponse putWatchResponse = watcherClient().preparePutWatch("1").setSource(watchBuilder() + .trigger(schedule(interval("10m"))) + .input(simpleInput("ref", "HipChatServiceIT#testWatchWithHipChatAction")) + .condition(alwaysCondition()) + .addAction("hipchat", actionBuilder)) + .execute().get(); + + assertThat(putWatchResponse.isCreated(), is(true)); + + timeWarp().scheduler().trigger("1"); + flush(); + refresh(); + + assertWatchWithMinimumPerformedActionsCount("1", 1L, false); + + SearchResponse response = searchHistory(searchSource().query(boolQuery() + .must(termQuery("result.actions.id", "hipchat")) + .must(termQuery("result.actions.type", "hipchat")) + .must(termQuery("result.actions.status", "success")) + .must(termQuery("result.actions.hipchat.account", account)) + .must(termQuery("result.actions.hipchat.sent_messages.status", "success")))); + + assertThat(response, notNullValue()); + assertThat(response.getHits().getTotalHits(), is(1L)); + } +} diff --git a/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/IntegrationAccountTests.java b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/IntegrationAccountTests.java new file mode 100644 index 00000000000..f1f633dbd81 --- /dev/null +++ b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/IntegrationAccountTests.java @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.watcher.support.http.*; +import org.junit.Test; + +import java.io.IOException; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.*; + +/** + * + */ +public class IntegrationAccountTests extends ESTestCase { + + @Test + public void testSettings() throws Exception { + String accountName = "_name"; + + Settings.Builder sb = Settings.builder(); + + String authToken = randomAsciiOfLength(50); + sb.put(IntegrationAccount.AUTH_TOKEN_SETTING, authToken); + + String host = HipChatServer.DEFAULT.host(); + if (randomBoolean()) { + host = randomAsciiOfLength(10); + sb.put(HipChatServer.HOST_SETTING, host); + } + int port = HipChatServer.DEFAULT.port(); + if (randomBoolean()) { + port = randomIntBetween(300, 400); + sb.put(HipChatServer.PORT_SETTING, port); + } + + String room = randomAsciiOfLength(10); + sb.put(IntegrationAccount.ROOM_SETTING, room); + + HipChatMessage.Format defaultFormat = null; + if (randomBoolean()) { + defaultFormat = randomFrom(HipChatMessage.Format.values()); + sb.put(HipChatAccount.DEFAULT_FORMAT_SETTING, defaultFormat); + } + HipChatMessage.Color defaultColor = null; + if (randomBoolean()) { + defaultColor = randomFrom(HipChatMessage.Color.values()); + sb.put(HipChatAccount.DEFAULT_COLOR_SETTING, defaultColor); + } + Boolean defaultNotify = null; + if (randomBoolean()) { + defaultNotify = randomBoolean(); + sb.put(HipChatAccount.DEFAULT_NOTIFY_SETTING, defaultNotify); + } + Settings settings = sb.build(); + + IntegrationAccount account = new IntegrationAccount(accountName, settings, HipChatServer.DEFAULT, mock(HttpClient.class), mock(ESLogger.class)); + + assertThat(account.profile, is(HipChatAccount.Profile.INTEGRATION)); + assertThat(account.name, equalTo(accountName)); + assertThat(account.server.host(), is(host)); + assertThat(account.server.port(), is(port)); + assertThat(account.authToken, is(authToken)); + assertThat(account.room, is(room)); + assertThat(account.defaults.format, is(defaultFormat)); + assertThat(account.defaults.color, is(defaultColor)); + assertThat(account.defaults.notify, is(defaultNotify)); + } + + @Test(expected = SettingsException.class) + public void testSettings_NoAuthToken() throws Exception { + Settings.Builder sb = Settings.builder(); + sb.put(IntegrationAccount.ROOM_SETTING, randomAsciiOfLength(10)); + new IntegrationAccount("_name", sb.build(), HipChatServer.DEFAULT, mock(HttpClient.class), mock(ESLogger.class)); + } + + @Test(expected = SettingsException.class) + public void testSettings_WithoutRoom() throws Exception { + Settings.Builder sb = Settings.builder(); + sb.put(IntegrationAccount.AUTH_TOKEN_SETTING, randomAsciiOfLength(50)); + new IntegrationAccount("_name", sb.build(), HipChatServer.DEFAULT, mock(HttpClient.class), mock(ESLogger.class)); + } + + @Test(expected = SettingsException.class) + public void testSettings_WithoutMultipleRooms() throws Exception { + Settings.Builder sb = Settings.builder(); + sb.put(IntegrationAccount.AUTH_TOKEN_SETTING, randomAsciiOfLength(50)); + sb.put(IntegrationAccount.ROOM_SETTING, "_r1,_r2"); + new IntegrationAccount("_name", sb.build(), HipChatServer.DEFAULT, mock(HttpClient.class), mock(ESLogger.class)); + } + + @Test + public void testSend() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + IntegrationAccount account = new IntegrationAccount("_name", Settings.builder() + .put("host", "_host") + .put("port", "443") + .put("auth_token", "_token") + .put("room", "_room") + .build(), HipChatServer.DEFAULT, httpClient, mock(ESLogger.class)); + + HipChatMessage.Format format = randomFrom(HipChatMessage.Format.values()); + HipChatMessage.Color color = randomFrom(HipChatMessage.Color.values()); + Boolean notify = randomBoolean(); + final HipChatMessage message = new HipChatMessage("_body", null, null, null, format, color, notify); + + HttpRequest req = HttpRequest.builder("_host", 443) + .method(HttpMethod.POST) + .scheme(Scheme.HTTPS) + .path("/v2/room/_room/notification") + .setHeader("Content-Type", "application/json") + .setHeader("Authorization", "Bearer _token") + .body(XContentHelper.toString(new ToXContent() { + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field("message", message.body); + if (message.format != null) { + builder.field("message_format", message.format.value()); + } + if (message.notify != null) { + builder.field("notify", message.notify); + } + if (message.color != null) { + builder.field("color", String.valueOf(message.color.value())); + } + return builder; + } + })) + .build(); + + HttpResponse res = mock(HttpResponse.class); + when(res.status()).thenReturn(200); + when(httpClient.execute(req)).thenReturn(res); + + account.send(message); + + verify(httpClient).execute(req); + } +} diff --git a/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/InternalHipChatServiceTests.java b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/InternalHipChatServiceTests.java new file mode 100644 index 00000000000..3647112e5a0 --- /dev/null +++ b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/InternalHipChatServiceTests.java @@ -0,0 +1,281 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; +import org.elasticsearch.node.settings.NodeSettingsService; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.watcher.shield.WatcherSettingsFilter; +import org.elasticsearch.watcher.support.http.HttpClient; +import org.junit.Before; +import org.junit.Test; + +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.*; + +/** + * + */ +public class InternalHipChatServiceTests extends ESTestCase { + + private HttpClient httpClient; + private NodeSettingsService nodeSettingsService; + private WatcherSettingsFilter settingsFilter; + + @Before + public void init() throws Exception { + httpClient = mock(HttpClient.class); + nodeSettingsService = mock(NodeSettingsService.class); + settingsFilter = mock(WatcherSettingsFilter.class); + } + + @Test + public void testSingleAccount_V1() throws Exception { + String accountName = randomAsciiOfLength(10); + String host = randomBoolean() ? null : "_host"; + int port = randomBoolean() ? -1 : randomIntBetween(300, 400); + String defaultRoom = randomBoolean() ? null : "_r1, _r2"; + String defaultFrom = randomBoolean() ? null : "_from"; + HipChatMessage.Color defaultColor = randomBoolean() ? null : randomFrom(HipChatMessage.Color.values()); + HipChatMessage.Format defaultFormat = randomBoolean() ? null : randomFrom(HipChatMessage.Format.values()); + Boolean defaultNotify = randomBoolean() ? null : (Boolean) randomBoolean(); + Settings.Builder settingsBuilder = Settings.builder() + .put("watcher.actions.hipchat.service.account." + accountName + ".profile", HipChatAccount.Profile.V1.value()) + .put("watcher.actions.hipchat.service.account." + accountName + ".auth_token", "_token"); + if (host != null) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + accountName + ".host", host); + } + if (port > 0) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + accountName + ".port", port); + } + buildMessageDefaults(accountName, settingsBuilder, defaultRoom, null, defaultFrom, defaultColor, defaultFormat, defaultNotify); + InternalHipChatService service = new InternalHipChatService(settingsBuilder.build(), httpClient, nodeSettingsService, settingsFilter); + service.start(); + + HipChatAccount account = service.getAccount(accountName); + assertThat(account, notNullValue()); + assertThat(account.name, is(accountName)); + assertThat(account.authToken, is("_token")); + assertThat(account.profile, is(HipChatAccount.Profile.V1)); + assertThat(account.httpClient, is(httpClient)); + assertThat(account.server, notNullValue()); + assertThat(account.server.host(), is(host != null ? host : HipChatServer.DEFAULT.host())); + assertThat(account.server.port(), is(port > 0 ? port : HipChatServer.DEFAULT.port())); + assertThat(account, instanceOf(V1Account.class)); + if (defaultRoom == null) { + assertThat(((V1Account) account).defaults.rooms, nullValue()); + } else { + assertThat(((V1Account) account).defaults.rooms, arrayContaining("_r1", "_r2")); + } + assertThat(((V1Account) account).defaults.from, is(defaultFrom)); + assertThat(((V1Account) account).defaults.color, is(defaultColor)); + assertThat(((V1Account) account).defaults.format, is(defaultFormat)); + assertThat(((V1Account) account).defaults.notify, is(defaultNotify)); + + // with a single account defined, making sure that that account is set to the default one. + assertThat(service.getDefaultAccount(), sameInstance(account)); + + assertThatSettingsFilterWasAdded(); + } + + @Test + public void testSingleAccount_Integration() throws Exception { + String accountName = randomAsciiOfLength(10); + String host = randomBoolean() ? null : "_host"; + int port = randomBoolean() ? -1 : randomIntBetween(300, 400); + String room = randomAsciiOfLength(10); + String defaultFrom = randomBoolean() ? null : "_from"; + HipChatMessage.Color defaultColor = randomBoolean() ? null : randomFrom(HipChatMessage.Color.values()); + HipChatMessage.Format defaultFormat = randomBoolean() ? null : randomFrom(HipChatMessage.Format.values()); + Boolean defaultNotify = randomBoolean() ? null : (Boolean) randomBoolean(); + Settings.Builder settingsBuilder = Settings.builder() + .put("watcher.actions.hipchat.service.account." + accountName + ".profile", HipChatAccount.Profile.INTEGRATION.value()) + .put("watcher.actions.hipchat.service.account." + accountName + ".auth_token", "_token") + .put("watcher.actions.hipchat.service.account." + accountName + ".room", room); + if (host != null) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + accountName + ".host", host); + } + if (port > 0) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + accountName + ".port", port); + } + buildMessageDefaults(accountName, settingsBuilder, null, null, defaultFrom, defaultColor, defaultFormat, defaultNotify); + InternalHipChatService service = new InternalHipChatService(settingsBuilder.build(), httpClient, nodeSettingsService, settingsFilter); + service.start(); + + HipChatAccount account = service.getAccount(accountName); + assertThat(account, notNullValue()); + assertThat(account.name, is(accountName)); + assertThat(account.authToken, is("_token")); + assertThat(account.profile, is(HipChatAccount.Profile.INTEGRATION)); + assertThat(account.httpClient, is(httpClient)); + assertThat(account.server, notNullValue()); + assertThat(account.server.host(), is(host != null ? host : HipChatServer.DEFAULT.host())); + assertThat(account.server.port(), is(port > 0 ? port : HipChatServer.DEFAULT.port())); + assertThat(account, instanceOf(IntegrationAccount.class)); + assertThat(((IntegrationAccount) account).room, is(room)); + assertThat(((IntegrationAccount) account).defaults.color, is(defaultColor)); + assertThat(((IntegrationAccount) account).defaults.format, is(defaultFormat)); + assertThat(((IntegrationAccount) account).defaults.notify, is(defaultNotify)); + + // with a single account defined, making sure that that account is set to the default one. + assertThat(service.getDefaultAccount(), sameInstance(account)); + + assertThatSettingsFilterWasAdded(); + } + + @Test(expected = SettingsException.class) + public void testSingleAccount_Integration_NoRoomSetting() throws Exception { + String accountName = randomAsciiOfLength(10); + Settings.Builder settingsBuilder = Settings.builder() + .put("watcher.actions.hipchat.service.account." + accountName + ".profile", HipChatAccount.Profile.INTEGRATION.value()) + .put("watcher.actions.hipchat.service.account." + accountName + ".auth_token", "_token"); + InternalHipChatService service = new InternalHipChatService(settingsBuilder.build(), httpClient, nodeSettingsService, settingsFilter); + service.start(); + } + + @Test + public void testSingleAccount_User() throws Exception { + String accountName = randomAsciiOfLength(10); + String host = randomBoolean() ? null : "_host"; + int port = randomBoolean() ? -1 : randomIntBetween(300, 400); + String defaultRoom = randomBoolean() ? null : "_r1, _r2"; + String defaultUser = randomBoolean() ? null : "_u1, _u2"; + HipChatMessage.Color defaultColor = randomBoolean() ? null : randomFrom(HipChatMessage.Color.values()); + HipChatMessage.Format defaultFormat = randomBoolean() ? null : randomFrom(HipChatMessage.Format.values()); + Boolean defaultNotify = randomBoolean() ? null : (Boolean) randomBoolean(); + Settings.Builder settingsBuilder = Settings.builder() + .put("watcher.actions.hipchat.service.account." + accountName + ".profile", HipChatAccount.Profile.USER.value()) + .put("watcher.actions.hipchat.service.account." + accountName + ".auth_token", "_token"); + if (host != null) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + accountName + ".host", host); + } + if (port > 0) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + accountName + ".port", port); + } + buildMessageDefaults(accountName, settingsBuilder, defaultRoom, defaultUser, null, defaultColor, defaultFormat, defaultNotify); + InternalHipChatService service = new InternalHipChatService(settingsBuilder.build(), httpClient, nodeSettingsService, settingsFilter); + service.start(); + + HipChatAccount account = service.getAccount(accountName); + assertThat(account, notNullValue()); + assertThat(account.name, is(accountName)); + assertThat(account.authToken, is("_token")); + assertThat(account.profile, is(HipChatAccount.Profile.USER)); + assertThat(account.httpClient, is(httpClient)); + assertThat(account.server, notNullValue()); + assertThat(account.server.host(), is(host != null ? host : HipChatServer.DEFAULT.host())); + assertThat(account.server.port(), is(port > 0 ? port : HipChatServer.DEFAULT.port())); + assertThat(account, instanceOf(UserAccount.class)); + if (defaultRoom == null) { + assertThat(((UserAccount) account).defaults.rooms, nullValue()); + } else { + assertThat(((UserAccount) account).defaults.rooms, arrayContaining("_r1", "_r2")); + } + if (defaultUser == null) { + assertThat(((UserAccount) account).defaults.users, nullValue()); + } else { + assertThat(((UserAccount) account).defaults.users, arrayContaining("_u1", "_u2")); + } + assertThat(((UserAccount) account).defaults.color, is(defaultColor)); + assertThat(((UserAccount) account).defaults.format, is(defaultFormat)); + assertThat(((UserAccount) account).defaults.notify, is(defaultNotify)); + + // with a single account defined, making sure that that account is set to the default one. + assertThat(service.getDefaultAccount(), sameInstance(account)); + + assertThatSettingsFilterWasAdded(); + } + + @Test + public void testMultipleAccounts() throws Exception { + HipChatMessage.Color defaultColor = randomBoolean() ? null : randomFrom(HipChatMessage.Color.values()); + HipChatMessage.Format defaultFormat = randomBoolean() ? null : randomFrom(HipChatMessage.Format.values()); + Boolean defaultNotify = randomBoolean() ? null : (Boolean) randomBoolean(); + Settings.Builder settingsBuilder = Settings.builder(); + String defaultAccount = "_a" + randomIntBetween(0, 4); + settingsBuilder.put("watcher.actions.hipchat.service.default_account", defaultAccount); + + boolean customGlobalServer = randomBoolean(); + if (customGlobalServer) { + settingsBuilder.put("watcher.actions.hipchat.service.host", "_host_global"); + settingsBuilder.put("watcher.actions.hipchat.service.port", 299); + } + + for (int i = 0; i < 5; i++) { + String name = "_a" + i; + String prefix = "watcher.actions.hipchat.service.account." + name; + HipChatAccount.Profile profile = randomFrom(HipChatAccount.Profile.values()); + settingsBuilder.put(prefix + ".profile", profile); + settingsBuilder.put(prefix + ".auth_token", "_token" + i); + if (profile == HipChatAccount.Profile.INTEGRATION) { + settingsBuilder.put(prefix + ".room", "_room" + i); + } + if (i % 2 == 0) { + settingsBuilder.put(prefix + ".host", "_host" + i); + settingsBuilder.put(prefix + ".port", 300 + i); + } + buildMessageDefaults(name, settingsBuilder, null, null, null, defaultColor, defaultFormat, defaultNotify); + } + + InternalHipChatService service = new InternalHipChatService(settingsBuilder.build(), httpClient, nodeSettingsService, settingsFilter); + service.start(); + + for (int i = 0; i < 5; i++) { + String name = "_a" + i; + HipChatAccount account = service.getAccount(name); + assertThat(account, notNullValue()); + assertThat(account.name, is(name)); + assertThat(account.authToken, is("_token" + i)); + assertThat(account.profile, notNullValue()); + if (account.profile == HipChatAccount.Profile.INTEGRATION) { + assertThat(account, instanceOf(IntegrationAccount.class)); + assertThat(((IntegrationAccount) account).room, is("_room" + i)); + } + assertThat(account.httpClient, is(httpClient)); + assertThat(account.server, notNullValue()); + if (i % 2 == 0) { + assertThat(account.server.host(), is("_host" + i)); + assertThat(account.server.port(), is(300 + i)); + } else if (customGlobalServer) { + assertThat(account.server.host(), is("_host_global")); + assertThat(account.server.port(), is(299)); + } else { + assertThat(account.server.host(), is(HipChatServer.DEFAULT.host())); + assertThat(account.server.port(), is(HipChatServer.DEFAULT.port())); + } + } + + assertThat(service.getDefaultAccount(), sameInstance(service.getAccount(defaultAccount))); + + assertThatSettingsFilterWasAdded(); + } + + private void assertThatSettingsFilterWasAdded() { + verify(settingsFilter, times(1)).filterOut("watcher.actions.hipchat.service.account.*.auth_token"); + } + + private void buildMessageDefaults(String account, Settings.Builder settingsBuilder, String room, String user, String from, HipChatMessage.Color color, HipChatMessage.Format format, Boolean notify) { + if (room != null) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + account + ".message_defaults.room", room); + } + if (user != null) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + account + ".message_defaults.user", user); + } + if (from != null) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + account + ".message_defaults.from", from); + } + if (color != null) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + account + ".message_defaults.color", color.value()); + } + if (format != null) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + account + ".message_defaults.format", format); + } + if (notify != null) { + settingsBuilder.put("watcher.actions.hipchat.service.account." + account + ".message_defaults.notify", notify); + } + } +} diff --git a/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/UserAccountTests.java b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/UserAccountTests.java new file mode 100644 index 00000000000..8123e4da9eb --- /dev/null +++ b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/UserAccountTests.java @@ -0,0 +1,239 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.watcher.support.http.*; +import org.junit.Test; + +import java.io.IOException; + +import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.*; + +/** + * + */ +public class UserAccountTests extends ESTestCase { + + @Test + public void testSettings() throws Exception { + String accountName = "_name"; + + Settings.Builder sb = Settings.builder(); + + String authToken = randomAsciiOfLength(50); + sb.put(UserAccount.AUTH_TOKEN_SETTING, authToken); + + String host = HipChatServer.DEFAULT.host(); + if (randomBoolean()) { + host = randomAsciiOfLength(10); + sb.put(HipChatServer.HOST_SETTING, host); + } + int port = HipChatServer.DEFAULT.port(); + if (randomBoolean()) { + port = randomIntBetween(300, 400); + sb.put(HipChatServer.PORT_SETTING, port); + } + + String[] defaultRooms = null; + if (randomBoolean()) { + defaultRooms = new String[] { "_r1", "_r2" }; + sb.put(HipChatAccount.DEFAULT_ROOM_SETTING, "_r1,_r2"); + } + String[] defaultUsers = null; + if (randomBoolean()) { + defaultUsers = new String[] { "_u1", "_u2" }; + sb.put(HipChatAccount.DEFAULT_USER_SETTING, "_u1,_u2"); + } + HipChatMessage.Format defaultFormat = null; + if (randomBoolean()) { + defaultFormat = randomFrom(HipChatMessage.Format.values()); + sb.put(HipChatAccount.DEFAULT_FORMAT_SETTING, defaultFormat); + } + HipChatMessage.Color defaultColor = null; + if (randomBoolean()) { + defaultColor = randomFrom(HipChatMessage.Color.values()); + sb.put(HipChatAccount.DEFAULT_COLOR_SETTING, defaultColor); + } + Boolean defaultNotify = null; + if (randomBoolean()) { + defaultNotify = randomBoolean(); + sb.put(HipChatAccount.DEFAULT_NOTIFY_SETTING, defaultNotify); + } + Settings settings = sb.build(); + + UserAccount account = new UserAccount(accountName, settings, HipChatServer.DEFAULT, mock(HttpClient.class), mock(ESLogger.class)); + + assertThat(account.profile, is(HipChatAccount.Profile.USER)); + assertThat(account.name, equalTo(accountName)); + assertThat(account.server.host(), is(host)); + assertThat(account.server.port(), is(port)); + assertThat(account.authToken, is(authToken)); + if (defaultRooms != null) { + assertThat(account.defaults.rooms, arrayContaining(defaultRooms)); + } else { + assertThat(account.defaults.rooms, nullValue()); + } + if (defaultUsers != null) { + assertThat(account.defaults.users, arrayContaining(defaultUsers)); + } else { + assertThat(account.defaults.users, nullValue()); + } + assertThat(account.defaults.format, is(defaultFormat)); + assertThat(account.defaults.color, is(defaultColor)); + assertThat(account.defaults.notify, is(defaultNotify)); + } + + @Test(expected = SettingsException.class) + public void testSettings_NoAuthToken() throws Exception { + Settings.Builder sb = Settings.builder(); + new UserAccount("_name", sb.build(), HipChatServer.DEFAULT, mock(HttpClient.class), mock(ESLogger.class)); + } + + @Test + public void testSend() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + UserAccount account = new UserAccount("_name", Settings.builder() + .put("host", "_host") + .put("port", "443") + .put("auth_token", "_token") + .build(), HipChatServer.DEFAULT, httpClient, mock(ESLogger.class)); + + HipChatMessage.Format format = randomFrom(HipChatMessage.Format.values()); + HipChatMessage.Color color = randomFrom(HipChatMessage.Color.values()); + Boolean notify = randomBoolean(); + final HipChatMessage message = new HipChatMessage("_body", new String[] { "_r1", "_r2" }, new String[] { "_u1", "_u2" }, null, format, color, notify); + + HttpRequest reqR1 = HttpRequest.builder("_host", 443) + .method(HttpMethod.POST) + .scheme(Scheme.HTTPS) + .path("/v2/room/_r1/notification") + .setHeader("Content-Type", "application/json") + .setHeader("Authorization", "Bearer _token") + .body(XContentHelper.toString(new ToXContent() { + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field("message", message.body); + if (message.format != null) { + builder.field("message_format", message.format.value()); + } + if (message.notify != null) { + builder.field("notify", message.notify); + } + if (message.color != null) { + builder.field("color", String.valueOf(message.color.value())); + } + return builder; + } + })) + .build(); + + logger.info("expected (r1): " + jsonBuilder().value(reqR1).bytes().toUtf8()); + + HttpResponse resR1 = mock(HttpResponse.class); + when(resR1.status()).thenReturn(200); + when(httpClient.execute(reqR1)).thenReturn(resR1); + + HttpRequest reqR2 = HttpRequest.builder("_host", 443) + .method(HttpMethod.POST) + .scheme(Scheme.HTTPS) + .path("/v2/room/_r2/notification") + .setHeader("Content-Type", "application/json") + .setHeader("Authorization", "Bearer _token") + .body(XContentHelper.toString(new ToXContent() { + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field("message", message.body); + if (message.format != null) { + builder.field("message_format", message.format.value()); + } + if (message.notify != null) { + builder.field("notify", message.notify); + } + if (message.color != null) { + builder.field("color", String.valueOf(message.color.value())); + } + return builder; + } + })) + .build(); + + logger.info("expected (r2): " + jsonBuilder().value(reqR1).bytes().toUtf8()); + + HttpResponse resR2 = mock(HttpResponse.class); + when(resR2.status()).thenReturn(200); + when(httpClient.execute(reqR2)).thenReturn(resR2); + + HttpRequest reqU1 = HttpRequest.builder("_host", 443) + .method(HttpMethod.POST) + .scheme(Scheme.HTTPS) + .path("/v2/user/_u1/message") + .setHeader("Content-Type", "application/json") + .setHeader("Authorization", "Bearer _token") + .body(XContentHelper.toString(new ToXContent() { + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field("message", message.body); + if (message.format != null) { + builder.field("message_format", message.format.value()); + } + if (message.notify != null) { + builder.field("notify", message.notify); + } + return builder; + } + })) + .build(); + + logger.info("expected (u1): " + jsonBuilder().value(reqU1).bytes().toUtf8()); + + HttpResponse resU1 = mock(HttpResponse.class); + when(resU1.status()).thenReturn(200); + when(httpClient.execute(reqU1)).thenReturn(resU1); + + HttpRequest reqU2 = HttpRequest.builder("_host", 443) + .method(HttpMethod.POST) + .scheme(Scheme.HTTPS) + .path("/v2/user/_u2/message") + .setHeader("Content-Type", "application/json") + .setHeader("Authorization", "Bearer _token") + .body(XContentHelper.toString(new ToXContent() { + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field("message", message.body); + if (message.format != null) { + builder.field("message_format", message.format.value()); + } + if (message.notify != null) { + builder.field("notify", message.notify); + } + return builder; + } + })) + .build(); + + logger.info("expected (u2): " + jsonBuilder().value(reqU2).bytes().toUtf8()); + + HttpResponse resU2 = mock(HttpResponse.class); + when(resU2.status()).thenReturn(200); + when(httpClient.execute(reqU2)).thenReturn(resU2); + + account.send(message); + + verify(httpClient).execute(reqR1); + verify(httpClient).execute(reqR2); + verify(httpClient).execute(reqU2); + verify(httpClient).execute(reqU2); + } +} diff --git a/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/V1AccountTests.java b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/V1AccountTests.java new file mode 100644 index 00000000000..18b3813b7b2 --- /dev/null +++ b/watcher/src/test/java/org/elasticsearch/watcher/actions/hipchat/service/V1AccountTests.java @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.watcher.actions.hipchat.service; + +import org.elasticsearch.common.logging.ESLogger; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.watcher.support.http.*; +import org.junit.Test; + +import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.*; + +/** + * + */ +public class V1AccountTests extends ESTestCase { + + @Test + public void testSettings() throws Exception { + String accountName = "_name"; + + Settings.Builder sb = Settings.builder(); + + String authToken = randomAsciiOfLength(50); + sb.put(V1Account.AUTH_TOKEN_SETTING, authToken); + + String host = HipChatServer.DEFAULT.host(); + if (randomBoolean()) { + host = randomAsciiOfLength(10); + sb.put(HipChatServer.HOST_SETTING, host); + } + int port = HipChatServer.DEFAULT.port(); + if (randomBoolean()) { + port = randomIntBetween(300, 400); + sb.put(HipChatServer.PORT_SETTING, port); + } + + String[] defaultRooms = null; + if (randomBoolean()) { + defaultRooms = new String[] { "_r1", "_r2" }; + sb.put(HipChatAccount.DEFAULT_ROOM_SETTING, "_r1,_r2"); + } + String defaultFrom = null; + if (randomBoolean()) { + defaultFrom = randomAsciiOfLength(10); + sb.put(HipChatAccount.DEFAULT_FROM_SETTING, defaultFrom); + } + HipChatMessage.Format defaultFormat = null; + if (randomBoolean()) { + defaultFormat = randomFrom(HipChatMessage.Format.values()); + sb.put(HipChatAccount.DEFAULT_FORMAT_SETTING, defaultFormat); + } + HipChatMessage.Color defaultColor = null; + if (randomBoolean()) { + defaultColor = randomFrom(HipChatMessage.Color.values()); + sb.put(HipChatAccount.DEFAULT_COLOR_SETTING, defaultColor); + } + Boolean defaultNotify = null; + if (randomBoolean()) { + defaultNotify = randomBoolean(); + sb.put(HipChatAccount.DEFAULT_NOTIFY_SETTING, defaultNotify); + } + Settings settings = sb.build(); + + V1Account account = new V1Account(accountName, settings, HipChatServer.DEFAULT, mock(HttpClient.class), mock(ESLogger.class)); + + assertThat(account.profile, is(HipChatAccount.Profile.V1)); + assertThat(account.name, equalTo(accountName)); + assertThat(account.server.host(), is(host)); + assertThat(account.server.port(), is(port)); + assertThat(account.authToken, is(authToken)); + if (defaultRooms != null) { + assertThat(account.defaults.rooms, arrayContaining(defaultRooms)); + } else { + assertThat(account.defaults.rooms, nullValue()); + } + assertThat(account.defaults.from, is(defaultFrom)); + assertThat(account.defaults.format, is(defaultFormat)); + assertThat(account.defaults.color, is(defaultColor)); + assertThat(account.defaults.notify, is(defaultNotify)); + } + + @Test(expected = SettingsException.class) + public void testSettings_NoAuthToken() throws Exception { + Settings.Builder sb = Settings.builder(); + new V1Account("_name", sb.build(), HipChatServer.DEFAULT, mock(HttpClient.class), mock(ESLogger.class)); + } + + @Test + public void testSend() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + V1Account account = new V1Account("_name", Settings.builder() + .put("host", "_host") + .put("port", "443") + .put("auth_token", "_token") + .build(), HipChatServer.DEFAULT, httpClient, mock(ESLogger.class)); + + HipChatMessage.Format format = randomFrom(HipChatMessage.Format.values()); + HipChatMessage.Color color = randomFrom(HipChatMessage.Color.values()); + Boolean notify = randomBoolean(); + HipChatMessage message = new HipChatMessage("_body", new String[] { "_r1", "_r2" }, null, "_from", format, color, notify); + + HttpRequest req1 = HttpRequest.builder("_host", 443) + .method(HttpMethod.POST) + .scheme(Scheme.HTTPS) + .path("/v1/rooms/message") + .setHeader("Content-Type", "application/x-www-form-urlencoded") + .setParam("format", "json") + .setParam("auth_token", "_token") + .body(new StringBuilder() + .append("room_id=").append("_r1&") + .append("from=").append("_from&") + .append("message=").append("_body&") + .append("message_format=").append(format.value()).append("&") + .append("color=").append(color.value()).append("&") + .append("notify=").append(notify ? "1" : "0") + .toString()) + .build(); + + logger.info("expected (r1): " + jsonBuilder().value(req1).bytes().toUtf8()); + + HttpResponse res1 = mock(HttpResponse.class); + when(res1.status()).thenReturn(200); + when(httpClient.execute(req1)).thenReturn(res1); + + HttpRequest req2 = HttpRequest.builder("_host", 443) + .method(HttpMethod.POST) + .scheme(Scheme.HTTPS) + .path("/v1/rooms/message") + .setHeader("Content-Type", "application/x-www-form-urlencoded") + .setParam("format", "json") + .setParam("auth_token", "_token") + .body(new StringBuilder() + .append("room_id=").append("_r2&") + .append("from=").append("_from&") + .append("message=").append("_body&") + .append("message_format=").append(format.value()).append("&") + .append("color=").append(color.value()).append("&") + .append("notify=").append(notify ? "1" : "0") + .toString()) + .build(); + + logger.info("expected (r2): " + jsonBuilder().value(req2).bytes().toUtf8()); + + HttpResponse res2 = mock(HttpResponse.class); + when(res2.status()).thenReturn(200); + when(httpClient.execute(req2)).thenReturn(res2); + + account.send(message); + + verify(httpClient).execute(req1); + verify(httpClient).execute(req2); + } +} diff --git a/watcher/src/test/java/org/elasticsearch/watcher/actions/index/IndexActionTests.java b/watcher/src/test/java/org/elasticsearch/watcher/actions/index/IndexActionTests.java index 0a4753fad5b..e93a904f724 100644 --- a/watcher/src/test/java/org/elasticsearch/watcher/actions/index/IndexActionTests.java +++ b/watcher/src/test/java/org/elasticsearch/watcher/actions/index/IndexActionTests.java @@ -15,7 +15,6 @@ import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentParser; import org.elasticsearch.common.xcontent.json.JsonXContent; -import org.elasticsearch.plugins.PluginsService; import org.elasticsearch.search.SearchHit; import org.elasticsearch.search.aggregations.bucket.terms.Terms; import org.elasticsearch.search.sort.SortOrder; diff --git a/watcher/src/test/java/org/elasticsearch/watcher/test/AbstractWatcherIntegrationTests.java b/watcher/src/test/java/org/elasticsearch/watcher/test/AbstractWatcherIntegrationTests.java index b9c11e48a7e..a6e64141045 100644 --- a/watcher/src/test/java/org/elasticsearch/watcher/test/AbstractWatcherIntegrationTests.java +++ b/watcher/src/test/java/org/elasticsearch/watcher/test/AbstractWatcherIntegrationTests.java @@ -327,6 +327,7 @@ public abstract class AbstractWatcherIntegrationTests extends ESIntegTestCase { assertWatchWithMinimumPerformedActionsCount(watchName, minimumExpectedWatchActionsWithActionPerformed, true); } + // TODO remove this shitty method... the `assertConditionMet` is bogus protected void assertWatchWithMinimumPerformedActionsCount(final String watchName, final long minimumExpectedWatchActionsWithActionPerformed, final boolean assertConditionMet) throws Exception { final AtomicReference lastResponse = new AtomicReference<>(); try { diff --git a/watcher/src/test/java/org/elasticsearch/watcher/test/integration/BasicWatcherTests.java b/watcher/src/test/java/org/elasticsearch/watcher/test/integration/BasicWatcherTests.java index d465172e678..c3a2e8829a7 100644 --- a/watcher/src/test/java/org/elasticsearch/watcher/test/integration/BasicWatcherTests.java +++ b/watcher/src/test/java/org/elasticsearch/watcher/test/integration/BasicWatcherTests.java @@ -5,7 +5,6 @@ */ package org.elasticsearch.watcher.test.integration; -import org.apache.lucene.util.LuceneTestCase; import org.elasticsearch.ElasticsearchParseException; import org.elasticsearch.action.search.SearchRequest; import org.elasticsearch.action.search.SearchRequestBuilder;