Browse Source

Variables in webhook URLs. Fixes #52

pull/60/head
Pēteris Caune 9 years ago
parent
commit
47f2105562
12 changed files with 262 additions and 31 deletions
  1. +20
    -0
      hc/api/migrations/0026_auto_20160415_1824.py
  2. +17
    -1
      hc/api/models.py
  3. +43
    -0
      hc/api/tests/test_notify.py
  4. +26
    -3
      hc/api/transports.py
  5. +10
    -0
      hc/front/forms.py
  6. +28
    -0
      hc/front/tests/test_add_channel.py
  7. +17
    -4
      hc/front/views.py
  8. +4
    -0
      static/css/channels.css
  9. +1
    -1
      templates/front/channel_checks.html
  10. +29
    -12
      templates/front/channels.html
  11. +1
    -1
      templates/integrations/add_slack.html
  12. +66
    -9
      templates/integrations/add_webhook.html

+ 20
- 0
hc/api/migrations/0026_auto_20160415_1824.py View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2016-04-15 18:24
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0025_auto_20160216_1214'),
]
operations = [
migrations.AlterField(
model_name='channel',
name='value',
field=models.TextField(blank=True),
),
]

+ 17
- 1
hc/api/models.py View File

@ -117,10 +117,14 @@ class Channel(models.Model):
user = models.ForeignKey(User) user = models.ForeignKey(User)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
kind = models.CharField(max_length=20, choices=CHANNEL_KINDS) kind = models.CharField(max_length=20, choices=CHANNEL_KINDS)
value = models.CharField(max_length=200, blank=True)
value = models.TextField(blank=True)
email_verified = models.BooleanField(default=False) email_verified = models.BooleanField(default=False)
checks = models.ManyToManyField(Check) checks = models.ManyToManyField(Check)
def assign_all_checks(self):
checks = Check.objects.filter(user=self.user)
self.checks.add(*checks)
def make_token(self): def make_token(self):
seed = "%s%s" % (self.code, settings.SECRET_KEY) seed = "%s%s" % (self.code, settings.SECRET_KEY)
seed = seed.encode("utf8") seed = seed.encode("utf8")
@ -176,6 +180,18 @@ class Channel(models.Model):
prio = int(prio) prio = int(prio)
return user_key, prio, PO_PRIORITIES[prio] return user_key, prio, PO_PRIORITIES[prio]
@property
def value_down(self):
assert self.kind == "webhook"
parts = self.value.split("\n")
return parts[0]
@property
def value_up(self):
assert self.kind == "webhook"
parts = self.value.split("\n")
return parts[1] if len(parts) == 2 else ""
def latest_notification(self): def latest_notification(self):
return Notification.objects.filter(channel=self).latest() return Notification.objects.filter(channel=self).latest()


+ 43
- 0
hc/api/tests/test_notify.py View File

@ -64,6 +64,49 @@ class NotifyTestCase(BaseTestCase):
n = Notification.objects.get() n = Notification.objects.get()
self.assertEqual(n.error, "Received status code 500") self.assertEqual(n.error, "Received status code 500")
@patch("hc.api.transports.requests.request")
def test_webhooks_support_variables(self, mock_get):
template = "http://host/$CODE/$STATUS/$TAG1/$TAG2/?name=$NAME"
self._setup_data("webhook", template)
self.check.name = "Hello World"
self.check.tags = "foo bar"
self.check.save()
self.channel.notify(self.check)
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)
@patch("hc.api.transports.requests.request")
def test_webhooks_dollarsign_escaping(self, mock_get):
# If name or tag contains what looks like a variable reference,
# that should be left alone:
template = "http://host/$NAME"
self._setup_data("webhook", template)
self.check.name = "$TAG1"
self.check.tags = "foo"
self.check.save()
self.channel.notify(self.check)
url = u"http://host/%24TAG1"
mock_get.assert_called_with(
"get", url, headers={"User-Agent": "healthchecks.io"}, timeout=5)
@patch("hc.api.transports.requests.request")
def test_webhook_fires_on_up_event(self, mock_get):
self._setup_data("webhook", "http://foo\nhttp://bar", status="up")
self.channel.notify(self.check)
mock_get.assert_called_with(
"get", "http://bar", headers={"User-Agent": "healthchecks.io"},
timeout=5)
def test_email(self): def test_email(self):
self._setup_data("email", "[email protected]") self._setup_data("email", "[email protected]")
self.channel.notify(self.check) self.channel.notify(self.check)


+ 26
- 3
hc/api/transports.py View File

@ -3,6 +3,7 @@ from django.template.loader import render_to_string
from django.utils import timezone from django.utils import timezone
import json import json
import requests import requests
from six.moves.urllib.parse import quote
from hc.lib import emails from hc.lib import emails
@ -81,11 +82,33 @@ class HttpTransport(Transport):
class Webhook(HttpTransport): class Webhook(HttpTransport):
def notify(self, check): def notify(self, check):
# Webhook integration only fires when check goes down.
if check.status != "down":
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" return "no-op"
return self.get(self.channel.value)
# 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): def test(self):
return self.get(self.channel.value) return self.get(self.channel.value)


+ 10
- 0
hc/front/forms.py View File

@ -31,3 +31,13 @@ class AddChannelForm(forms.ModelForm):
def clean_value(self): def clean_value(self):
value = self.cleaned_data["value"] value = self.cleaned_data["value"]
return value.strip() return value.strip()
class AddWebhookForm(forms.Form):
error_css_class = "has-error"
value_down = forms.URLField(max_length=1000, required=False)
value_up = forms.URLField(max_length=1000, required=False)
def get_value(self):
return "{value_down}\n{value_up}".format(**self.cleaned_data)

+ 28
- 0
hc/front/tests/test_add_channel.py View File

@ -81,3 +81,31 @@ class AddChannelTestCase(BaseTestCase):
params = "pushover_user_key=a&nonce=INVALID&prio=0" params = "pushover_user_key=a&nonce=INVALID&prio=0"
r = self.client.get("/integrations/add_pushover/?%s" % params) r = self.client.get("/integrations/add_pushover/?%s" % params)
assert r.status_code == 403 assert r.status_code == 403
def test_it_adds_two_webhook_urls_and_redirects(self):
form = {"value_down": "http://foo.com", "value_up": "https://bar.com"}
self.client.login(username="[email protected]", password="password")
r = self.client.post("/integrations/add_webhook/", form)
self.assertRedirects(r, "/integrations/")
c = Channel.objects.get()
self.assertEqual(c.value, "http://foo.com\nhttps://bar.com")
def test_it_rejects_non_http_webhook_urls(self):
form = {"value_down": "foo", "value_up": "bar"}
self.client.login(username="[email protected]", password="password")
r = self.client.post("/integrations/add_webhook/", form)
self.assertContains(r, "Enter a valid URL.")
self.assertEqual(Channel.objects.count(), 0)
def test_it_handles_empty_down_url(self):
form = {"value_down": "", "value_up": "http://foo.com"}
self.client.login(username="[email protected]", password="password")
self.client.post("/integrations/add_webhook/", form)
c = Channel.objects.get()
self.assertEqual(c.value, "\nhttp://foo.com")

+ 17
- 4
hc/front/views.py View File

@ -14,7 +14,8 @@ from django.utils.six.moves.urllib.parse import urlencode
from hc.accounts.models import Profile from hc.accounts.models import Profile
from hc.api.decorators import uuid_or_400 from hc.api.decorators import uuid_or_400
from hc.api.models import Channel, Check, Ping, DEFAULT_TIMEOUT, DEFAULT_GRACE from hc.api.models import Channel, Check, Ping, DEFAULT_TIMEOUT, DEFAULT_GRACE
from hc.front.forms import AddChannelForm, NameTagsForm, TimeoutForm
from hc.front.forms import (AddChannelForm, AddWebhookForm, NameTagsForm,
TimeoutForm)
# from itertools recipes: # from itertools recipes:
@ -302,8 +303,7 @@ def do_add_channel(request, data):
channel.user = request.user channel.user = request.user
channel.save() channel.save()
checks = Check.objects.filter(user=request.user)
channel.checks.add(*checks)
channel.assign_all_checks()
if channel.kind == "email": if channel.kind == "email":
channel.send_verify_link() channel.send_verify_link()
@ -372,7 +372,19 @@ def add_email(request):
@login_required @login_required
def add_webhook(request): def add_webhook(request):
ctx = {"page": "channels"}
if request.method == "POST":
form = AddWebhookForm(request.POST)
if form.is_valid():
channel = Channel(user=request.user, kind="webhook")
channel.value = form.get_value()
channel.save()
channel.assign_all_checks()
return redirect("hc-channels")
else:
form = AddWebhookForm()
ctx = {"page": "channels", "form": form}
return render(request, "integrations/add_webhook.html", ctx) return render(request, "integrations/add_webhook.html", ctx)
@ -454,6 +466,7 @@ def add_pushover(request):
} }
return render(request, "integrations/add_pushover.html", ctx) return render(request, "integrations/add_pushover.html", ctx)
@login_required @login_required
def add_victorops(request): def add_victorops(request):
ctx = {"page": "channels"} ctx = {"page": "channels"}


+ 4
- 0
static/css/channels.css View File

@ -169,4 +169,8 @@ table.channels-table > tbody > tr > th {
border: ; border: ;
max-width: 100%; max-width: 100%;
border: 6px solid #EEE; border: 6px solid #EEE;
}
.variable-column {
width: 160px;
} }

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

@ -5,7 +5,7 @@
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button> <button type="button" class="close" data-dismiss="modal">&times;</button>
<h4 class="update-timeout-title">Assign Checks to Channel {% if channel.kind == "po" %}{{ channel.po_value|join:" / " }}{% else %}{{ channel.value }}{% endif %}</h4>
<h4 class="update-timeout-title">Assign Checks to Channel</h4>
</div> </div>
<input type="hidden" name="channel" value="{{ channel.code }}" /> <input type="hidden" name="channel" value="{{ channel.code }}" />


+ 29
- 12
templates/front/channels.html View File

@ -28,23 +28,40 @@
{% if ch.kind == "victorops" %} VictorOps {% endif %} {% if ch.kind == "victorops" %} VictorOps {% endif %}
</td> </td>
<td class="value-cell"> <td class="value-cell">
<span class="preposition">
{% if ch.kind == "email" %} to {% endif %}
{% if ch.kind == "pd" %} API key {% endif %}
{% if ch.kind == "po" %} user key {% endif %}
{% if ch.kind == "victorops" %} Post URL {% endif %}
</span>
{% if ch.kind == "po" %}
{% if ch.kind == "email" %}
<span class="preposition">to</span>
{{ ch.value }}
{% if not ch.email_verified %}
<span class="channel-unconfirmed">(unconfirmed)</span>
{% endif %}
{% elif ch.kind == "pd" %}
<span class="preposition">API key</span>
{{ ch.value }}
{% elif ch.kind == "victorops" %}
<span class="preposition">Post URL</span>
{{ ch.value }}
{% elif ch.kind == "po" %}
<span class="preposition">user key</span>
{{ ch.po_value|first }} {{ ch.po_value|first }}
({{ ch.po_value|last }} priority) ({{ ch.po_value|last }} priority)
{% elif ch.kind == "webhook" %}
<table>
{% if ch.value_down %}
<tr>
<td class="preposition">down&nbsp;</td>
<td>{{ ch.value_down }}</td>
</tr>
{% endif %}
{% if ch.value_up %}
<tr>
<td class="preposition">up&nbsp;</td>
<td>{{ ch.value_up }}</td>
</tr>
{% endif %}
</table>
{% else %} {% else %}
{{ ch.value }} {{ ch.value }}
{% endif %} {% endif %}
{% if ch.kind == "email" and not ch.email_verified %}
<span class="channel-unconfirmed">(unconfirmed)
{% endif %}
</td> </td>
<td class="channels-num-checks"> <td class="channels-num-checks">
<a <a


+ 1
- 1
templates/integrations/add_slack.html View File

@ -55,7 +55,7 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="kind" value="slack" /> <input type="hidden" name="kind" value="slack" />
<div class="form-group"> <div class="form-group">
<label for="inputEmail3" class="col-sm-2 control-label">Callback URL</label>
<label class="col-sm-2 control-label">Callback URL</label>
<div class="col-sm-10"> <div class="col-sm-10">
<input type="text" class="form-control" name="value" placeholder=""> <input type="text" class="form-control" name="value" placeholder="">
</div> </div>


+ 66
- 9
templates/integrations/add_webhook.html View File

@ -1,27 +1,84 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load compress humanize staticfiles hc_extras %} {% load compress humanize staticfiles hc_extras %}
{% block title %}Add WebHook - healthchecks.io{% endblock %}
{% block title %}Add Webhook - healthchecks.io{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
<h1>WebHook</h1>
<h1>Webhook</h1>
<p>WebHooks are a simple way to notify an external system when a check
goes down. healthcheks.io will run a normal HTTP GET call to your
<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> specified URL.</p>
<p>You can use the following variables in webhook URLs:</p>
<table class="table webhook-variables">
<tr>
<th class="variable-column">Variable</th>
<td>Will be replaced with…</td>
</tr>
<tr>
<th><code>$CODE</code></th>
<td>The UUID code of the check</td>
</tr>
<tr>
<th><code>$NAME</code></th>
<td>Urlencoded name of the check</td>
</tr>
<tr>
<th><code>$STATUS</code></th>
<td>Check's current status ("up" or "down")</td>
</tr>
<tr>
<th><code>$TAG1, $TAG2, …</code></th>
<td>Urlencoded value of the first tag, the second tag, …</td>
</tr>
</table>
<p>For example, a callback URL using variables might look like so:
<pre>http://requestb.in/1hhct291?message=<strong>$NAME</strong>:<strong>$STATUS</strong></pre>
<p>
After encoding and replacing the variables, healthchecks.io would then call:
</p>
<pre>http://requestb.in/1hhct291?message=<strong>My%20Check</strong>:<strong>down</strong></pre>
<h2>Integration Settings</h2> <h2>Integration Settings</h2>
<form method="post" class="form-horizontal" action="{% url 'hc-add-channel' %}">
<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">
<label for="inputEmail3" class="col-sm-2 control-label">URL</label>
<div class="col-sm-3">
<input type="text" class="form-control" name="value" placeholder="http://...">
<div class="form-group {{ form.value_down.css_classes }}">
<label class="col-sm-2 control-label">URL for "down" events</label>
<div class="col-sm-10">
<input
type="text"
class="form-control"
name="value_down"
placeholder="http://..."
value="{{ form.value_down.value|default:"" }}">
{% if form.value_down.errors %}
<div class="help-block">
{{ form.value_down.errors|join:"" }}
</div>
{% endif %}
</div>
</div>
<div class="form-group {{ form.value_up.css_classes }}">
<label class="col-sm-2 control-label">URL for "up" events</label>
<div class="col-sm-10">
<input
type="text"
class="form-control"
name="value_up"
placeholder="http://..."
value="{{ form.value_up.value|default:"" }}">
{% if form.value_up.errors %}
<div class="help-block">
{{ form.value_up.errors|join:"" }}
</div>
{% endif %}
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">


Loading…
Cancel
Save