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

This commit is contained in:
someposer 2017-11-03 19:40:43 -05:00
parent ee0df8be95
commit 05c84d7976
8 changed files with 113 additions and 128 deletions

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") if not self.value.startswith("{"):
return parts[0] 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") if not self.value.startswith("{"):
return parts[1] if len(parts) > 1 else "" 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") if not self.value.startswith("{"):
return parts[2] if len(parts) > 2 else "" 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") if not self.value.startswith("{"):
return parts[3] if len(parts) > 3 else "" return ""
doc = json.loads(self.value)
return doc["headers"]
@property @property
def slack_team(self): def slack_team(self):

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): def test_webhooks_handle_json_value(self, mock_request):
template = u"http://example.com\n\n{}\napplication/json" self._setup_data("webhook", '{"url_down": "http://foo.com", '
self._setup_data("webhook", template) '"url_up": "", "post_data": "", "headers": ""}')
self.check.save()
headers = {
"User-Agent": "healthchecks.io",
"Content-Type": "application/json"
}
self.channel.notify(self.check) self.channel.notify(self.check)
headers = {
"User-Agent": "healthchecks.io"
}
mock_request.assert_called_with( mock_request.assert_called_with(
"post", "http://example.com", data=b"{}", headers=headers, timeout=5) "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): def test_email(self):
self._setup_data("email", "alice@example.org") self._setup_data("email", "alice@example.org")
@ -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", "not_alice@example.org")
self.profile.sort = "name"
self.profile.save()
self.channel.notify(self.check)
args, kwargs = mock_emails.call_args
self.assertEqual(args[0], "not_alice@example.org")
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)

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",

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 json.dumps(self.cleaned_data)
return "\n".join((d["value_down"], d["value_up"], d["post_data"], d["content_type"]))
phone_validator = RegexValidator(regex='^\+\d{5,15}$', phone_validator = RegexValidator(regex='^\+\d{5,15}$',

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="alice@example.org", password="password") self.client.login(username="alice@example.org", 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="alice@example.org", password="password") self.client.login(username="alice@example.org", 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="alice@example.org", password="password") self.client.login(username="alice@example.org", 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="alice@example.org", password="password") self.client.login(username="alice@example.org", 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): def test_it_adds_headers(self):
form = {"value_down": "http://foo.com", "post_data": "hello", "content_type": "application/json"} form = {"url_down": "http://foo.com", "headers": '{"test": "123"}'}
self.client.login(username="alice@example.org", password="password") self.client.login(username="alice@example.org", 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\\\"}"}')

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 class="preposition">headers&nbsp;</td>
<td>{{ ch.content_type }}</td> <td>{{ ch.headers }}</td>
</tr> </tr>
{% endif %} {% endif %}
</table> </table>

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 %}

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:"" }}"> value="{{ form.url_down.value|default:"" }}">
{% if form.value_down.errors %} {% 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:"" }}"> value="{{ form.url_up.value|default:"" }}">
{% if form.value_up.errors %} {% 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 }}"> <div class="form-group {{ form.headers.css_classes }}">
<label class="col-sm-2 control-label">Content-Type</label> <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" name="headers"
placeholder='application/json' placeholder='{"Content-Type": "application/json"}'
value="{{ form.content_type.value|default:"" }}"> value="{{ form.headers.value|default:"" }}">
{% if form.content_type.errors %} {% 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>