Webhooks support POST, cleanup.

This commit is contained in:
Pēteris Caune 2017-01-21 18:29:55 +02:00
parent 891efc720a
commit c16eeda004
9 changed files with 170 additions and 58 deletions

View File

@ -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"]

View File

@ -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")

View File

@ -83,14 +83,43 @@ 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 prepare(self, template, check, urlencode=False):
""" 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
"""
def safe(s):
return quote(s) if urlencode else s
result = template
if "$CODE" in result:
result = result.replace("$CODE", str(check.code))
if "$STATUS" in result:
result = result.replace("$STATUS", check.status)
if "$NOW" in result:
s = timezone.now().replace(microsecond=0).isoformat()
result = result.replace("$NOW", safe(s))
if "$NAME" in result:
result = result.replace("$NAME", safe(check.name))
if "$TAG" in result:
for i, tag in enumerate(check.tags_list()):
placeholder = "$TAG%d" % (i + 1)
result = result.replace(placeholder, safe(tag))
return result
def notify(self, check):
url = self.channel.value_down
if check.status == "up":
@ -100,35 +129,19 @@ class Webhook(HttpTransport):
# If the URL is empty then we do nothing
return "no-op"
# 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
if "$CODE" in url:
url = url.replace("$CODE", str(check.code))
if "$STATUS" in url:
url = url.replace("$STATUS", check.status)
if "$NAME" in url:
url = url.replace("$NAME", quote(check.name))
if "$TAG" in url:
for i, tag in enumerate(check.tags_list()):
placeholder = "$TAG%d" % (i + 1)
url = url.replace(placeholder, quote(tag))
return self.get(url)
def test(self):
return self.get(self.channel.value)
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)

View File

@ -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"]))

View File

@ -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")

View File

@ -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, "<td>http://down.example.com</td>")
self.assertContains(r, "<td>http://up.example.com</td>")
self.assertContains(r, "<td>foobar</td>")
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)")

View File

@ -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)

View File

@ -81,6 +81,12 @@
<td>{{ ch.value_up }}</td>
</tr>
{% endif %}
{% if ch.post_data %}
<tr>
<td class="preposition">body&nbsp;</td>
<td>{{ ch.post_data }}</td>
</tr>
{% endif %}
</table>
{% elif ch.kind == "pushbullet" %}
<span class="preposition">API key</span>

View File

@ -9,9 +9,10 @@
<div class="col-sm-12">
<h1>Webhook</h1>
<p>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.</p>
<p>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.</p>
<p>You can use the following variables in webhook URLs:</p>
<table class="table webhook-variables">
<tr>
@ -24,7 +25,14 @@
</tr>
<tr>
<th><code>$NAME</code></th>
<td>Urlencoded name of the check</td>
<td>Name of the check</td>
</tr>
<tr>
<th><code>$NOW</code></th>
<td>
Current UTC time in ISO8601 format.
Example: "{{ now }}"
</td>
</tr>
<tr>
<th><code>$STATUS</code></th>
@ -32,7 +40,7 @@
</tr>
<tr>
<th><code>$TAG1, $TAG2, …</code></th>
<td>Urlencoded value of the first tag, the second tag, …</td>
<td>Value of the first tag, the second tag, …</td>
</tr>
</table>
@ -81,6 +89,22 @@
{% endif %}
</div>
</div>
<div class="form-group {{ form.post_data.css_classes }}">
<label class="col-sm-2 control-label">POST data</label>
<div class="col-sm-10">
<input
type="text"
class="form-control"
name="post_data"
placeholder='{"status": "$STATUS"}'
value="{{ form.post_data.value|default:"" }}">
{% if form.post_data.errors %}
<div class="help-block">
{{ form.post_data.errors|join:"" }}
</div>
{% endif %}
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary">Save Integration</button>