Browse Source

Add support for arbitrary headers using a JSON body for webhooks.

pull/140/head
someposer 7 years ago
parent
commit
05c84d7976
8 changed files with 109 additions and 124 deletions
  1. +27
    -11
      hc/api/models.py
  2. +31
    -63
      hc/api/tests/test_notify.py
  3. +8
    -8
      hc/api/transports.py
  4. +5
    -5
      hc/front/forms.py
  5. +13
    -12
      hc/front/tests/test_add_webhook.py
  6. +7
    -7
      templates/front/channels.html
  7. +1
    -1
      templates/front/log.html
  8. +17
    -17
      templates/integrations/add_webhook.html

+ 27
- 11
hc/api/models.py View File

@ -304,28 +304,44 @@ class Channel(models.Model):
return user_key, prio, PO_PRIORITIES[prio] return user_key, prio, PO_PRIORITIES[prio]
@property @property
def value_down(self):
def url_down(self):
assert self.kind == "webhook" 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 @property
def value_up(self):
def url_up(self):
assert self.kind == "webhook" 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 @property
def post_data(self): def post_data(self):
assert self.kind == "webhook" 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 @property
def content_type(self):
def headers(self):
assert self.kind == "webhook" assert self.kind == "webhook"
parts = self.value.split("\n")
return parts[3] if len(parts) > 3 else ""
if not self.value.startswith("{"):
return ""
doc = json.loads(self.value)
return doc["headers"]
@property @property
def slack_team(self): def slack_team(self):


+ 31
- 63
hc/api/tests/test_notify.py View File

@ -5,7 +5,6 @@ import json
from django.core import mail from django.core import mail
from django.utils.timezone import now from django.utils.timezone import now
from hc.api.transports import Transport
from hc.api.models import Channel, Check, Notification from hc.api.models import Channel, Check, Notification
from hc.test import BaseTestCase from hc.test import BaseTestCase
from mock import patch from mock import patch
@ -63,14 +62,6 @@ class NotifyTestCase(BaseTestCase):
self.assertFalse(mock_get.called) self.assertFalse(mock_get.called)
self.assertEqual(Notification.objects.count(), 0) self.assertEqual(Notification.objects.count(), 0)
@patch("hc.api.transports.requests.request")
def test_webhooks_ignore_down_events(self, mock_get):
self._setup_data("webhook", "\nhttp://example", status="down")
self.channel.notify(self.check)
self.assertFalse(mock_get.called)
self.assertEqual(Notification.objects.count(), 0)
@patch("hc.api.transports.requests.request") @patch("hc.api.transports.requests.request")
def test_webhooks_handle_500(self, mock_get): def test_webhooks_handle_500(self, mock_get):
self._setup_data("webhook", "http://example") self._setup_data("webhook", "http://example")
@ -155,20 +146,44 @@ class NotifyTestCase(BaseTestCase):
self.assertTrue(isinstance(kwargs["data"], binary_type)) self.assertTrue(isinstance(kwargs["data"], binary_type))
@patch("hc.api.transports.requests.request") @patch("hc.api.transports.requests.request")
def test_webhooks_handle_content_type(self, mock_request):
template = u"http://example.com\n\n{}\napplication/json"
self._setup_data("webhook", template)
self.check.save()
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 = { headers = {
"User-Agent": "healthchecks.io",
"Content-Type": "application/json"
"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) self.channel.notify(self.check)
headers = {
"User-Agent": "healthchecks.io",
"Content-Type": "application/json"
}
mock_request.assert_called_with( mock_request.assert_called_with(
"post", "http://example.com", data=b"{}", headers=headers, timeout=5)
"post", "http://foo.com", data=b"data", headers=headers, timeout=5)
def test_email(self): def test_email(self):
self._setup_data("email", "[email protected]") self._setup_data("email", "[email protected]")
@ -192,17 +207,6 @@ class NotifyTestCase(BaseTestCase):
self.assertEqual(n.error, "Email not verified") self.assertEqual(n.error, "Email not verified")
self.assertEqual(len(mail.outbox), 0) self.assertEqual(len(mail.outbox), 0)
@patch("hc.api.transports.emails.alert")
def test_email_missing_profile(self, mock_emails):
self._setup_data("email", "[email protected]")
self.profile.sort = "name"
self.profile.save()
self.channel.notify(self.check)
args, kwargs = mock_emails.call_args
self.assertEqual(args[0], "[email protected]")
self.assertEqual(args[1]["sort"], "created")
@patch("hc.api.transports.requests.request") @patch("hc.api.transports.requests.request")
def test_pd(self, mock_post): def test_pd(self, mock_post):
self._setup_data("pd", "123") self._setup_data("pd", "123")
@ -312,21 +316,6 @@ class NotifyTestCase(BaseTestCase):
payload = kwargs["json"] payload = kwargs["json"]
self.assertIn("DOWN", payload["message"]) self.assertIn("DOWN", payload["message"])
@patch("hc.api.transports.requests.request")
def test_opsgenie_up(self, mock_post):
self._setup_data("opsgenie", "123", status="up")
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
n = Notification.objects.first()
self.assertEqual(n.error, "")
args, kwargs = mock_post.call_args
payload = kwargs["json"]
self.assertEqual(args[0], "post")
self.assertTrue(args[1].endswith("/close"))
self.assertNotIn("message", payload)
@patch("hc.api.transports.requests.request") @patch("hc.api.transports.requests.request")
def test_pushover(self, mock_post): def test_pushover(self, mock_post):
self._setup_data("po", "123|0") self._setup_data("po", "123|0")
@ -338,22 +327,6 @@ class NotifyTestCase(BaseTestCase):
args, kwargs = mock_post.call_args args, kwargs = mock_post.call_args
payload = kwargs["data"] payload = kwargs["data"]
self.assertIn("DOWN", payload["title"]) self.assertIn("DOWN", payload["title"])
self.assertNotIn("retry", payload)
self.assertNotIn("expire", payload)
@patch("hc.api.transports.requests.request")
def test_pushover_emergency(self, mock_post):
self._setup_data("po", "123|2")
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
assert Notification.objects.count() == 1
args, kwargs = mock_post.call_args
payload = kwargs["data"]
self.assertIn("DOWN", payload["title"])
self.assertIn("retry", payload)
self.assertIn("expire", payload)
@patch("hc.api.transports.requests.request") @patch("hc.api.transports.requests.request")
def test_victorops(self, mock_post): def test_victorops(self, mock_post):
@ -454,8 +427,3 @@ class NotifyTestCase(BaseTestCase):
self.channel.notify(self.check) self.channel.notify(self.check)
self.assertTrue(mock_post.called) self.assertTrue(mock_post.called)
def test_transport_notify(self):
self._setup_data("webhook", "http://example")
with self.assertRaises(NotImplementedError):
Transport(self.channel).notify(self.check)

+ 8
- 8
hc/api/transports.py View File

@ -147,27 +147,27 @@ class Webhook(HttpTransport):
return result return result
def is_noop(self, check): 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 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 True
return False return False
def notify(self, check): def notify(self, check):
url = self.channel.value_down
url = self.channel.url_down
if check.status == "up": if check.status == "up":
url = self.channel.value_up
url = self.channel.url_up
assert url assert url
url = self.prepare(url, check, urlencode=True) url = self.prepare(url, check, urlencode=True)
if self.channel.post_data: if self.channel.post_data:
headers = {}
if self.channel.content_type:
headers["Content-Type"] = self.channel.content_type
payload = self.prepare(self.channel.post_data, check) payload = self.prepare(self.channel.post_data, check)
headers = {}
if self.channel.headers:
headers = json.loads(self.channel.headers)
return self.post(url, data=payload.encode("utf-8"), headers=headers) return self.post(url, data=payload.encode("utf-8"), headers=headers)
else: else:
return self.get(url) return self.get(url)
@ -233,7 +233,7 @@ class Pushbullet(HttpTransport):
url = "https://api.pushbullet.com/v2/pushes" url = "https://api.pushbullet.com/v2/pushes"
headers = { headers = {
"Access-Token": self.channel.value, "Access-Token": self.channel.value,
"Content-Type": "application/json"
"Conent-Type": "application/json"
} }
payload = { payload = {
"type": "note", "type": "note",


+ 5
- 5
hc/front/forms.py View File

@ -1,3 +1,4 @@
import json
from datetime import timedelta as td from datetime import timedelta as td
from django import forms from django import forms
@ -57,19 +58,18 @@ class AddUrlForm(forms.Form):
class AddWebhookForm(forms.Form): class AddWebhookForm(forms.Form):
error_css_class = "has-error" 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()]) validators=[WebhookValidator()])
value_up = forms.URLField(max_length=1000, required=False,
url_up = forms.URLField(max_length=1000, required=False,
validators=[WebhookValidator()]) validators=[WebhookValidator()])
post_data = forms.CharField(max_length=1000, required=False) post_data = forms.CharField(max_length=1000, required=False)
content_type = forms.CharField(max_length=1000, required=False)
headers = forms.CharField(max_length=1000, required=False)
def get_value(self): def get_value(self):
d = self.cleaned_data
return "\n".join((d["value_down"], d["value_up"], d["post_data"], d["content_type"]))
return json.dumps(self.cleaned_data)
phone_validator = RegexValidator(regex='^\+\d{5,15}$', phone_validator = RegexValidator(regex='^\+\d{5,15}$',


+ 13
- 12
hc/front/tests/test_add_webhook.py View File

@ -11,17 +11,17 @@ class AddWebhookTestCase(BaseTestCase):
self.assertContains(r, "Runs a HTTP GET or HTTP POST") self.assertContains(r, "Runs a HTTP GET or HTTP POST")
def test_it_adds_two_webhook_urls_and_redirects(self): 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="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form) r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/") self.assertRedirects(r, "/integrations/")
c = Channel.objects.get() c = Channel.objects.get()
self.assertEqual(c.value, "http://foo.com\nhttps://bar.com\n\n")
self.assertEqual(c.value, '{"url_down": "http://foo.com", "url_up": "https://bar.com", "post_data": "", "headers": ""}')
def test_it_adds_webhook_using_team_access(self): 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 # Logging in as bob, not alice. Bob has team access so this
# should work. # should work.
@ -30,7 +30,7 @@ class AddWebhookTestCase(BaseTestCase):
c = Channel.objects.get() c = Channel.objects.get()
self.assertEqual(c.user, self.alice) self.assertEqual(c.user, self.alice)
self.assertEqual(c.value, "http://foo.com\nhttps://bar.com\n\n")
self.assertEqual(c.value, '{"url_down": "http://foo.com", "url_up": "https://bar.com", "post_data": "", "headers": ""}')
def test_it_rejects_bad_urls(self): def test_it_rejects_bad_urls(self):
urls = [ urls = [
@ -45,7 +45,7 @@ class AddWebhookTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
for url in urls: for url in urls:
form = {"value_down": url, "value_up": ""}
form = {"url_down": url, "url_up": ""}
r = self.client.post(self.url, form) r = self.client.post(self.url, form)
self.assertContains(r, "Enter a valid URL.", msg_prefix=url) self.assertContains(r, "Enter a valid URL.", msg_prefix=url)
@ -53,30 +53,31 @@ class AddWebhookTestCase(BaseTestCase):
self.assertEqual(Channel.objects.count(), 0) self.assertEqual(Channel.objects.count(), 0)
def test_it_handles_empty_down_url(self): 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="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
self.client.post(self.url, form) self.client.post(self.url, form)
c = Channel.objects.get() c = Channel.objects.get()
self.assertEqual(c.value, "\nhttp://foo.com\n\n")
self.assertEqual(c.value, '{"url_down": "", "url_up": "http://foo.com", "post_data": "", "headers": ""}')
def test_it_adds_post_data(self): 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="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form) r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/") self.assertRedirects(r, "/integrations/")
c = Channel.objects.get() c = Channel.objects.get()
self.assertEqual(c.value, "http://foo.com\n\nhello\n")
self.assertEqual(c.value, '{"url_down": "http://foo.com", "url_up": "", "post_data": "hello", "headers": ""}')
def test_it_adds_content_type(self):
form = {"value_down": "http://foo.com", "post_data": "hello", "content_type": "application/json"}
def test_it_adds_headers(self):
form = {"url_down": "http://foo.com", "headers": '{"test": "123"}'}
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form) r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/") self.assertRedirects(r, "/integrations/")
c = Channel.objects.get() c = Channel.objects.get()
self.assertEqual(c.value, "http://foo.com\n\nhello\napplication/json")
self.assertEqual(c.value, '{"url_down": "http://foo.com", "url_up": "", "post_data": "", "headers": "{\\\"test\\\": \\\"123\\\"}"}')

+ 7
- 7
templates/front/channels.html View File

@ -62,16 +62,16 @@
{% endif %} {% endif %}
{% elif ch.kind == "webhook" %} {% elif ch.kind == "webhook" %}
<table> <table>
{% if ch.value_down %}
{% if ch.url_down %}
<tr> <tr>
<td class="preposition">down&nbsp;</td> <td class="preposition">down&nbsp;</td>
<td>{{ ch.value_down }}</td>
<td>{{ ch.url_down }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if ch.value_up %}
{% if ch.url_up %}
<tr> <tr>
<td class="preposition">up&nbsp;</td> <td class="preposition">up&nbsp;</td>
<td>{{ ch.value_up }}</td>
<td>{{ ch.url_up }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if ch.post_data %} {% if ch.post_data %}
@ -80,10 +80,10 @@
<td>{{ ch.post_data }}</td> <td>{{ ch.post_data }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if ch.content_type %}
{% if ch.headers %}
<tr> <tr>
<td class="preposition">type&nbsp;</td>
<td>{{ ch.content_type }}</td>
<td class="preposition">headers&nbsp;</td>
<td>{{ ch.headers }}</td>
</tr> </tr>
{% endif %} {% endif %}
</table> </table>


+ 1
- 1
templates/front/log.html View File

@ -96,7 +96,7 @@
{% elif event.channel.kind == "po" %} {% elif event.channel.kind == "po" %}
Sent a Pushover notification Sent a Pushover notification
{% elif event.channel.kind == "webhook" %} {% elif event.channel.kind == "webhook" %}
Called webhook {{ event.channel.value_down }}
Called webhook {{ event.channel.url_down }}
{% else %} {% else %}
Sent alert to {{ event.channel.kind|capfirst }} Sent alert to {{ event.channel.kind|capfirst }}
{% endif %} {% endif %}


+ 17
- 17
templates/integrations/add_webhook.html View File

@ -57,34 +57,34 @@
<form method="post" class="form-horizontal"> <form method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="kind" value="webhook" /> <input type="hidden" name="kind" value="webhook" />
<div class="form-group {{ form.value_down.css_classes }}">
<div class="form-group {{ form.url_down.css_classes }}">
<label class="col-sm-2 control-label">URL for "down" events</label> <label class="col-sm-2 control-label">URL for "down" events</label>
<div class="col-sm-10"> <div class="col-sm-10">
<input <input
type="text" type="text"
class="form-control" class="form-control"
name="value_down"
name="url_down"
placeholder="http://..." placeholder="http://..."
value="{{ form.value_down.value|default:"" }}">
{% if form.value_down.errors %}
value="{{ form.url_down.value|default:"" }}">
{% if form.url_down.errors %}
<div class="help-block"> <div class="help-block">
{{ form.value_down.errors|join:"" }}
{{ form.url_down.errors|join:"" }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="form-group {{ form.value_up.css_classes }}">
<div class="form-group {{ form.url_up.css_classes }}">
<label class="col-sm-2 control-label">URL for "up" events</label> <label class="col-sm-2 control-label">URL for "up" events</label>
<div class="col-sm-10"> <div class="col-sm-10">
<input <input
type="text" type="text"
class="form-control" class="form-control"
name="value_up"
name="url_up"
placeholder="http://..." placeholder="http://..."
value="{{ form.value_up.value|default:"" }}">
{% if form.value_up.errors %}
value="{{ form.url_up.value|default:"" }}">
{% if form.url_up.errors %}
<div class="help-block"> <div class="help-block">
{{ form.value_up.errors|join:"" }}
{{ form.url_up.errors|join:"" }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
@ -105,18 +105,18 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="form-group {{ form.content_type.css_classes }}">
<label class="col-sm-2 control-label">Content-Type</label>
<div class="form-group {{ form.headers.css_classes }}">
<label class="col-sm-2 control-label">Custom Headers</label>
<div class="col-sm-10"> <div class="col-sm-10">
<input <input
type="text" type="text"
class="form-control" class="form-control"
name="content_type"
placeholder='application/json'
value="{{ form.content_type.value|default:"" }}">
{% if form.content_type.errors %}
name="headers"
placeholder='{"Content-Type": "application/json"}'
value="{{ form.headers.value|default:"" }}">
{% if form.headers.errors %}
<div class="help-block"> <div class="help-block">
{{ form.content_type.errors|join:"" }}
{{ form.headers.errors|join:"" }}
</div> </div>
{% endif %} {% endif %}
</div> </div>


Loading…
Cancel
Save