From c16eeda0049c5f2a28d07494a46d516f621ca1a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sat, 21 Jan 2017 18:29:55 +0200 Subject: [PATCH] Webhooks support POST, cleanup. --- hc/api/models.py | 17 +++-- hc/api/tests/test_notify.py | 33 +++++++++- hc/api/transports.py | 82 +++++++++++++++---------- hc/front/forms.py | 5 +- hc/front/tests/test_add_webhook.py | 18 ++++-- hc/front/tests/test_channels.py | 25 ++++++++ hc/front/views.py | 6 +- templates/front/channels.html | 6 ++ templates/integrations/add_webhook.html | 34 ++++++++-- 9 files changed, 169 insertions(+), 57 deletions(-) diff --git a/hc/api/models.py b/hc/api/models.py index 1bfeead9..5f0c4223 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -269,9 +269,6 @@ class Channel(models.Model): return error - def test(self): - return self.transport().test() - @property def po_value(self): assert self.kind == "po" @@ -289,7 +286,13 @@ class Channel(models.Model): def value_up(self): assert self.kind == "webhook" parts = self.value.split("\n") - return parts[1] if len(parts) == 2 else "" + return parts[1] if len(parts) > 1 else "" + + @property + def post_data(self): + assert self.kind == "webhook" + parts = self.value.split("\n") + return parts[2] if len(parts) > 2 else "" @property def slack_team(self): @@ -321,18 +324,12 @@ class Channel(models.Model): @property def discord_webhook_url(self): assert self.kind == "discord" - if not self.value.startswith("{"): - return self.value - doc = json.loads(self.value) return doc["webhook"]["url"] @property def discord_webhook_id(self): assert self.kind == "discord" - if not self.value.startswith("{"): - return self.value - doc = json.loads(self.value) return doc["webhook"]["id"] diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index 7fa09135..aeb6c158 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -80,8 +80,25 @@ class NotifyTestCase(BaseTestCase): url = u"http://host/%s/down/foo/bar/?name=Hello%%20World" \ % self.check.code - mock_get.assert_called_with( - "get", url, headers={"User-Agent": "healthchecks.io"}, timeout=5) + args, kwargs = mock_get.call_args + self.assertEqual(args[0], "get") + self.assertEqual(args[1], url) + self.assertEqual(kwargs["headers"], {"User-Agent": "healthchecks.io"}) + self.assertEqual(kwargs["timeout"], 5) + + @patch("hc.api.transports.requests.request") + def test_webhooks_support_post(self, mock_request): + template = "http://example.com\n\nThe Time Is $NOW" + self._setup_data("webhook", template) + self.check.save() + + self.channel.notify(self.check) + args, kwargs = mock_request.call_args + self.assertEqual(args[0], "post") + self.assertEqual(args[1], "http://example.com") + + # spaces should not have been urlencoded: + self.assertTrue(kwargs["data"].startswith("The Time Is 2")) @patch("hc.api.transports.requests.request") def test_webhooks_dollarsign_escaping(self, mock_get): @@ -267,3 +284,15 @@ class NotifyTestCase(BaseTestCase): attachment = payload["attachments"][0] fields = {f["title"]: f["value"] for f in attachment["fields"]} self.assertEqual(fields["Last Ping"], "Never") + + @patch("hc.api.transports.requests.request") + def test_pushbullet(self, mock_post): + self._setup_data("pushbullet", "fake-token") + mock_post.return_value.status_code = 200 + + self.channel.notify(self.check) + assert Notification.objects.count() == 1 + + _, kwargs = mock_post.call_args + self.assertEqual(kwargs["json"]["type"], "note") + self.assertEqual(kwargs["headers"]["Access-Token"], "fake-token") diff --git a/hc/api/transports.py b/hc/api/transports.py index 4f04d7a1..893547b1 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -83,52 +83,65 @@ class HttpTransport(Transport): def get(self, url): return self.request("get", url) - def post(self, url, json, **kwargs): - return self.request("post", url, json=json, **kwargs) - - def post_form(self, url, data): - return self.request("post", url, data=data) + def post(self, url, **kwargs): + return self.request("post", url, **kwargs) class Webhook(HttpTransport): - def notify(self, check): - url = self.channel.value_down - if check.status == "up": - url = self.channel.value_up + def prepare(self, template, check, urlencode=False): + """ Replace variables with actual values. - if not url: - # If the URL is empty then we do nothing - return "no-op" + There should be no bad translations if users use $ symbol in + check's name or tags, because $ gets urlencoded to %24 + + """ + + def safe(s): + return quote(s) if urlencode else s - # Replace variables with actual values. - # There should be no bad translations if users use $ symbol in - # check's name or tags, because $ gets urlencoded to %24 + result = template + if "$CODE" in result: + result = result.replace("$CODE", str(check.code)) - if "$CODE" in url: - url = url.replace("$CODE", str(check.code)) + if "$STATUS" in result: + result = result.replace("$STATUS", check.status) - if "$STATUS" in url: - url = url.replace("$STATUS", check.status) + if "$NOW" in result: + s = timezone.now().replace(microsecond=0).isoformat() + result = result.replace("$NOW", safe(s)) - if "$NAME" in url: - url = url.replace("$NAME", quote(check.name)) + if "$NAME" in result: + result = result.replace("$NAME", safe(check.name)) - if "$TAG" in url: + if "$TAG" in result: for i, tag in enumerate(check.tags_list()): placeholder = "$TAG%d" % (i + 1) - url = url.replace(placeholder, quote(tag)) + result = result.replace(placeholder, safe(tag)) - return self.get(url) + return result - def test(self): - return self.get(self.channel.value) + def notify(self, check): + url = self.channel.value_down + if check.status == "up": + url = self.channel.value_up + + if not url: + # If the URL is empty then we do nothing + return "no-op" + + url = self.prepare(url, check, urlencode=True) + if self.channel.post_data: + payload = self.prepare(self.channel.post_data, check) + return self.post(url, data=payload) + else: + return self.get(url) class Slack(HttpTransport): def notify(self, check): text = tmpl("slack_message.json", check=check) payload = json.loads(text) - return self.post(self.channel.slack_webhook_url, payload) + return self.post(self.channel.slack_webhook_url, json=payload) class HipChat(HttpTransport): @@ -138,7 +151,7 @@ class HipChat(HttpTransport): "message": text, "color": "green" if check.status == "up" else "red", } - return self.post(self.channel.value, payload) + return self.post(self.channel.value, json=payload) class OpsGenie(HttpTransport): @@ -159,7 +172,7 @@ class OpsGenie(HttpTransport): if check.status == "up": url += "/close" - return self.post(url, payload) + return self.post(url, json=payload) class PagerDuty(HttpTransport): @@ -176,7 +189,7 @@ class PagerDuty(HttpTransport): "client_url": settings.SITE_ROOT } - return self.post(self.URL, payload) + return self.post(self.URL, json=payload) class Pushbullet(HttpTransport): @@ -193,7 +206,7 @@ class Pushbullet(HttpTransport): "body": text } - return self.post(url, payload, headers=headers) + return self.post(url, json=payload, headers=headers) class Pushover(HttpTransport): @@ -222,7 +235,7 @@ class Pushover(HttpTransport): payload["retry"] = settings.PUSHOVER_EMERGENCY_RETRY_DELAY payload["expire"] = settings.PUSHOVER_EMERGENCY_EXPIRATION - return self.post_form(self.URL, payload) + return self.post(self.URL, data=payload) class VictorOps(HttpTransport): @@ -236,11 +249,12 @@ class VictorOps(HttpTransport): "monitoring_tool": "healthchecks.io", } - return self.post(self.channel.value, payload) + return self.post(self.channel.value, json=payload) class Discord(HttpTransport): def notify(self, check): text = tmpl("slack_message.json", check=check) payload = json.loads(text) - return self.post(self.channel.discord_webhook_url + "/slack", payload) + url = self.channel.discord_webhook_url + "/slack" + return self.post(url, json=payload) diff --git a/hc/front/forms.py b/hc/front/forms.py index d6dcac01..88f43e4f 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -60,5 +60,8 @@ class AddWebhookForm(forms.Form): value_up = forms.URLField(max_length=1000, required=False, validators=[WebhookValidator()]) + post_data = forms.CharField(max_length=1000, required=False) + def get_value(self): - return "{value_down}\n{value_up}".format(**self.cleaned_data) + d = self.cleaned_data + return "\n".join((d["value_down"], d["value_up"], d["post_data"])) diff --git a/hc/front/tests/test_add_webhook.py b/hc/front/tests/test_add_webhook.py index 276799c5..a97a97fe 100644 --- a/hc/front/tests/test_add_webhook.py +++ b/hc/front/tests/test_add_webhook.py @@ -8,7 +8,7 @@ class AddWebhookTestCase(BaseTestCase): def test_instructions_work(self): self.client.login(username="alice@example.org", password="password") r = self.client.get(self.url) - self.assertContains(r, "Webhooks are a simple way") + self.assertContains(r, "Runs a HTTP GET or HTTP POST") def test_it_adds_two_webhook_urls_and_redirects(self): form = {"value_down": "http://foo.com", "value_up": "https://bar.com"} @@ -18,7 +18,7 @@ class AddWebhookTestCase(BaseTestCase): self.assertRedirects(r, "/integrations/") c = Channel.objects.get() - self.assertEqual(c.value, "http://foo.com\nhttps://bar.com") + self.assertEqual(c.value, "http://foo.com\nhttps://bar.com\n") def test_it_adds_webhook_using_team_access(self): form = {"value_down": "http://foo.com", "value_up": "https://bar.com"} @@ -30,7 +30,7 @@ class AddWebhookTestCase(BaseTestCase): c = Channel.objects.get() self.assertEqual(c.user, self.alice) - self.assertEqual(c.value, "http://foo.com\nhttps://bar.com") + self.assertEqual(c.value, "http://foo.com\nhttps://bar.com\n") def test_it_rejects_bad_urls(self): urls = [ @@ -59,4 +59,14 @@ class AddWebhookTestCase(BaseTestCase): self.client.post(self.url, form) c = Channel.objects.get() - self.assertEqual(c.value, "\nhttp://foo.com") + self.assertEqual(c.value, "\nhttp://foo.com\n") + + def test_it_adds_post_data(self): + form = {"value_down": "http://foo.com", "post_data": "hello"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertRedirects(r, "/integrations/") + + c = Channel.objects.get() + self.assertEqual(c.value, "http://foo.com\n\nhello") diff --git a/hc/front/tests/test_channels.py b/hc/front/tests/test_channels.py index a133073b..ab194d30 100644 --- a/hc/front/tests/test_channels.py +++ b/hc/front/tests/test_channels.py @@ -22,3 +22,28 @@ class ChannelsTestCase(BaseTestCase): r = self.client.get("/integrations/") self.assertContains(r, "foo-team", status_code=200) self.assertContains(r, "#bar") + + def test_it_shows_webhook_post_data(self): + ch = Channel(kind="webhook", user=self.alice) + ch.value = "http://down.example.com\nhttp://up.example.com\nfoobar" + ch.save() + + self.client.login(username="alice@example.org", password="password") + r = self.client.get("/integrations/") + + self.assertEqual(r.status_code, 200) + self.assertContains(r, "http://down.example.com") + self.assertContains(r, "http://up.example.com") + self.assertContains(r, "foobar") + + def test_it_shows_pushover_details(self): + ch = Channel(kind="po", user=self.alice) + ch.value = "fake-key|0" + ch.save() + + self.client.login(username="alice@example.org", password="password") + r = self.client.get("/integrations/") + + self.assertEqual(r.status_code, 200) + self.assertContains(r, "fake-key") + self.assertContains(r, "(normal priority)") diff --git a/hc/front/views.py b/hc/front/views.py index 4492ae8c..47c6a90e 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -410,7 +410,11 @@ def add_webhook(request): else: form = AddWebhookForm() - ctx = {"page": "channels", "form": form} + ctx = { + "page": "channels", + "form": form, + "now": timezone.now().replace(microsecond=0).isoformat() + } return render(request, "integrations/add_webhook.html", ctx) diff --git a/templates/front/channels.html b/templates/front/channels.html index 9b427dcf..e43748ca 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -81,6 +81,12 @@ {{ ch.value_up }} {% endif %} + {% if ch.post_data %} + + body  + {{ ch.post_data }} + + {% endif %} {% elif ch.kind == "pushbullet" %} API key diff --git a/templates/integrations/add_webhook.html b/templates/integrations/add_webhook.html index a9927f90..4ef91b01 100644 --- a/templates/integrations/add_webhook.html +++ b/templates/integrations/add_webhook.html @@ -9,9 +9,10 @@

Webhook

-

Webhooks are a simple way to notify an external system when a check - goes up or down. healthcheks.io will run a normal HTTP GET call to your - specified URL.

+

Runs a HTTP GET or HTTP POST to your specified URL when a check + goes up or down. Uses GET by default, and uses POST if you specify + any POST data.

+

You can use the following variables in webhook URLs:

@@ -24,7 +25,14 @@ - + + + + + @@ -32,7 +40,7 @@ - +
$NAMEUrlencoded name of the checkName of the check
$NOW + Current UTC time in ISO8601 format. + Example: "{{ now }}" +
$STATUS
$TAG1, $TAG2, …Urlencoded value of the first tag, the second tag, …Value of the first tag, the second tag, …
@@ -81,6 +89,22 @@ {% endif %}
+
+ +
+ + {% if form.post_data.errors %} +
+ {{ form.post_data.errors|join:"" }} +
+ {% endif %} +
+