diff --git a/hc/api/models.py b/hc/api/models.py index 3fa9d2ca..05ae3ac6 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -304,22 +304,44 @@ class Channel(models.Model): return user_key, prio, PO_PRIORITIES[prio] @property - def value_down(self): + def url_down(self): assert self.kind == "webhook" - parts = self.value.split("\n") - return parts[0] + if not self.value.startswith("{"): + parts = self.value.split("\n") + return parts[0] + + doc = json.loads(self.value) + return doc["url_down"] + @property - def value_up(self): + def url_up(self): assert self.kind == "webhook" - parts = self.value.split("\n") - return parts[1] if len(parts) > 1 else "" + if not self.value.startswith("{"): + parts = self.value.split("\n") + return parts[1] if len(parts) > 1 else "" + + doc = json.loads(self.value) + return doc["url_up"] @property def post_data(self): assert self.kind == "webhook" - parts = self.value.split("\n") - return parts[2] if len(parts) > 2 else "" + if not self.value.startswith("{"): + parts = self.value.split("\n") + return parts[2] if len(parts) > 2 else "" + + doc = json.loads(self.value) + return doc["post_data"] + + @property + def headers(self): + assert self.kind == "webhook" + if not self.value.startswith("{"): + return "" + + doc = json.loads(self.value) + return doc["headers"] @property def slack_team(self): diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index a65d608a..10265e25 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -145,6 +145,46 @@ class NotifyTestCase(BaseTestCase): # unicode should be encoded into utf-8 self.assertTrue(isinstance(kwargs["data"], binary_type)) + @patch("hc.api.transports.requests.request") + def test_webhooks_handle_json_value(self, mock_request): + self._setup_data("webhook", '{"url_down": "http://foo.com", ' + '"url_up": "", "post_data": "", "headers": ""}') + self.channel.notify(self.check) + + headers = { + "User-Agent": "healthchecks.io" + } + mock_request.assert_called_with( + "get", "http://foo.com", headers=headers, + timeout=5) + + @patch("hc.api.transports.requests.request") + def test_webhooks_handle_json_up_event(self, mock_request): + self._setup_data("webhook", '{"url_down": "", ' + '"url_up": "http://bar", "post_data": "", "headers": ""}', status="up") + self.channel.notify(self.check) + + headers = { + "User-Agent": "healthchecks.io" + } + mock_request.assert_called_with( + "get", "http://bar", headers=headers, + timeout=5) + + @patch("hc.api.transports.requests.request") + def test_webhooks_handle_headers(self, mock_request): + self._setup_data("webhook", '{"url_down": "http://foo.com", ' + '"url_up": "", "post_data": "data", ' + '"headers": {"Content-Type": "application/json"}}') + self.channel.notify(self.check) + + headers = { + "User-Agent": "healthchecks.io", + "Content-Type": "application/json" + } + mock_request.assert_called_with( + "post", "http://foo.com", data=b"data", headers=headers, timeout=5) + def test_email(self): self._setup_data("email", "alice@example.org") self.channel.notify(self.check) diff --git a/hc/api/transports.py b/hc/api/transports.py index 9329bb9d..325a0899 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -150,25 +150,28 @@ class Webhook(HttpTransport): return result def is_noop(self, check): - if check.status == "down" and not self.channel.value_down: + if check.status == "down" and not self.channel.url_down: return True - if check.status == "up" and not self.channel.value_up: + if check.status == "up" and not self.channel.url_up: return True return False def notify(self, check): - url = self.channel.value_down + url = self.channel.url_down if check.status == "up": - url = self.channel.value_up + url = self.channel.url_up assert url 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.encode("utf-8")) + headers = {} + if self.channel.headers: + headers = self.channel.headers + return self.post(url, data=payload.encode("utf-8"), headers=headers) else: return self.get(url) diff --git a/hc/front/forms.py b/hc/front/forms.py index 1617a5ef..3308787b 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -1,3 +1,4 @@ +import json from datetime import timedelta as td from django import forms @@ -57,17 +58,30 @@ class AddUrlForm(forms.Form): class AddWebhookForm(forms.Form): error_css_class = "has-error" - value_down = forms.URLField(max_length=1000, required=False, + url_down = forms.URLField(max_length=1000, required=False, validators=[WebhookValidator()]) - value_up = forms.URLField(max_length=1000, required=False, + url_up = forms.URLField(max_length=1000, required=False, validators=[WebhookValidator()]) post_data = forms.CharField(max_length=1000, required=False) + def __init__(self, *args, **kwargs): + self.headers = {} + if all(k in kwargs for k in ("header_keys", "header_values")): + header_keys = kwargs.pop("header_keys") + header_values = kwargs.pop("header_values") + + for i, (key, val) in enumerate(zip(header_keys, header_values)): + if key: + self.headers[key] = val + + super(AddWebhookForm, self).__init__(*args, **kwargs) + def get_value(self): - d = self.cleaned_data - return "\n".join((d["value_down"], d["value_up"], d["post_data"])) + val = dict(self.cleaned_data) + val["headers"] = self.headers + return json.dumps(val, sort_keys=True) phone_validator = RegexValidator(regex='^\+\d{5,15}$', diff --git a/hc/front/tests/test_add_webhook.py b/hc/front/tests/test_add_webhook.py index a97a97fe..744f5686 100644 --- a/hc/front/tests/test_add_webhook.py +++ b/hc/front/tests/test_add_webhook.py @@ -11,17 +11,17 @@ class AddWebhookTestCase(BaseTestCase): 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"} + form = {"url_down": "http://foo.com", "url_up": "https://bar.com"} 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\nhttps://bar.com\n") + self.assertEqual(c.value, '{"headers": {}, "post_data": "", "url_down": "http://foo.com", "url_up": "https://bar.com"}') def test_it_adds_webhook_using_team_access(self): - form = {"value_down": "http://foo.com", "value_up": "https://bar.com"} + form = {"url_down": "http://foo.com", "url_up": "https://bar.com"} # Logging in as bob, not alice. Bob has team access so this # should work. @@ -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\n") + self.assertEqual(c.value, '{"headers": {}, "post_data": "", "url_down": "http://foo.com", "url_up": "https://bar.com"}') def test_it_rejects_bad_urls(self): urls = [ @@ -45,7 +45,7 @@ class AddWebhookTestCase(BaseTestCase): self.client.login(username="alice@example.org", password="password") for url in urls: - form = {"value_down": url, "value_up": ""} + form = {"url_down": url, "url_up": ""} r = self.client.post(self.url, form) self.assertContains(r, "Enter a valid URL.", msg_prefix=url) @@ -53,20 +53,31 @@ class AddWebhookTestCase(BaseTestCase): self.assertEqual(Channel.objects.count(), 0) def test_it_handles_empty_down_url(self): - form = {"value_down": "", "value_up": "http://foo.com"} + form = {"url_down": "", "url_up": "http://foo.com"} self.client.login(username="alice@example.org", password="password") self.client.post(self.url, form) c = Channel.objects.get() - self.assertEqual(c.value, "\nhttp://foo.com\n") + self.assertEqual(c.value, '{"headers": {}, "post_data": "", "url_down": "", "url_up": "http://foo.com"}') def test_it_adds_post_data(self): - form = {"value_down": "http://foo.com", "post_data": "hello"} + form = {"url_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") + self.assertEqual(c.value, '{"headers": {}, "post_data": "hello", "url_down": "http://foo.com", "url_up": ""}') + + def test_it_adds_headers(self): + form = {"url_down": "http://foo.com", "header_key[]": ["test", "test2"], "header_value[]": ["123", "abc"]} + + 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, '{"headers": {"test": "123", "test2": "abc"}, "post_data": "", "url_down": "http://foo.com", "url_up": ""}') + diff --git a/hc/front/views.py b/hc/front/views.py index 22853577..972ef126 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -437,7 +437,10 @@ def add_email(request): @login_required def add_webhook(request): if request.method == "POST": - form = AddWebhookForm(request.POST) + header_keys = request.POST.getlist('header_key[]') + header_values = request.POST.getlist('header_value[]') + form = AddWebhookForm(request.POST or None, + header_keys=header_keys, header_values=header_values) if form.is_valid(): channel = Channel(user=request.team.user, kind="webhook") channel.value = form.get_value() diff --git a/static/js/webhook.js b/static/js/webhook.js new file mode 100644 index 00000000..9b22b380 --- /dev/null +++ b/static/js/webhook.js @@ -0,0 +1,29 @@ +$(function() { + $(".webhook_header_btn:first").addClass("btn-info").text("+") + $(".webhook_header_btn:not(:first)").addClass("btn-danger").text("X") + + $("#webhook_headers").on("click", ".webhook_header_btn.btn-danger", function(e) { + e.preventDefault(); + $(this).closest("div.row").remove(); + }); + + $("#webhook_headers").on("click", ".webhook_header_btn.btn-info", function(e) { + e.preventDefault(); + + // Add new header form + $("#webhook_headers").append( +'
down | -{{ ch.value_down }} | +{{ ch.url_down }} |
up | -{{ ch.value_up }} | +{{ ch.url_up }} | {{ ch.post_data }} | {% endif %} + {% if ch.headers %} +
headers | +{{ ch.headers }} | +