Browse Source

Webhooks support POST, cleanup.

pull/114/head
Pēteris Caune 8 years ago
parent
commit
c16eeda004
9 changed files with 169 additions and 57 deletions
  1. +7
    -10
      hc/api/models.py
  2. +31
    -2
      hc/api/tests/test_notify.py
  3. +48
    -34
      hc/api/transports.py
  4. +4
    -1
      hc/front/forms.py
  5. +14
    -4
      hc/front/tests/test_add_webhook.py
  6. +25
    -0
      hc/front/tests/test_channels.py
  7. +5
    -1
      hc/front/views.py
  8. +6
    -0
      templates/front/channels.html
  9. +29
    -5
      templates/integrations/add_webhook.html

+ 7
- 10
hc/api/models.py 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"]


+ 31
- 2
hc/api/tests/test_notify.py 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")

+ 48
- 34
hc/api/transports.py View File

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

+ 4
- 1
hc/front/forms.py 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"]))

+ 14
- 4
hc/front/tests/test_add_webhook.py View File

@ -8,7 +8,7 @@ class AddWebhookTestCase(BaseTestCase):
def test_instructions_work(self):
self.client.login(username="[email protected]", 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="[email protected]", 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")

+ 25
- 0
hc/front/tests/test_channels.py 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="[email protected]", 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="[email protected]", password="password")
r = self.client.get("/integrations/")
self.assertEqual(r.status_code, 200)
self.assertContains(r, "fake-key")
self.assertContains(r, "(normal priority)")

+ 5
- 1
hc/front/views.py 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)


+ 6
- 0
templates/front/channels.html 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>


+ 29
- 5
templates/integrations/add_webhook.html 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>


Loading…
Cancel
Save