diff --git a/hc/api/models.py b/hc/api/models.py index 14dd3279..6aaf6b7f 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -144,11 +144,13 @@ class Check(models.Model): return [t.strip() for t in self.tags.split(" ") if t.strip()] def to_dict(self): + update_rel_url = reverse("hc-api-update", args=[self.code]) pause_rel_url = reverse("hc-api-pause", args=[self.code]) result = { "name": self.name, "ping_url": self.url(), + "update_url": settings.SITE_ROOT + update_rel_url, "pause_url": settings.SITE_ROOT + pause_rel_url, "tags": self.tags, "grace": int(self.grace.total_seconds()), diff --git a/hc/api/tests/test_list_checks.py b/hc/api/tests/test_list_checks.py index e2e5a922..1d7b4a41 100644 --- a/hc/api/tests/test_list_checks.py +++ b/hc/api/tests/test_list_checks.py @@ -48,7 +48,10 @@ class ListChecksTestCase(BaseTestCase): self.assertEqual(checks["Alice 1"]["last_ping"], self.now.isoformat()) self.assertEqual(checks["Alice 1"]["n_pings"], 1) self.assertEqual(checks["Alice 1"]["status"], "new") - pause_url = "{0}/api/v1/checks/%s/pause".format(getattr(settings, "SITE_ROOT")) % self.a1.code + + update_url = settings.SITE_ROOT + "/api/v1/checks/%s" % self.a1.code + pause_url = update_url + "/pause" + self.assertEqual(checks["Alice 1"]["update_url"], update_url) self.assertEqual(checks["Alice 1"]["pause_url"], pause_url) next_ping = self.now + td(seconds=3600) diff --git a/hc/api/tests/test_pause.py b/hc/api/tests/test_pause.py index 413dce73..cee8c62a 100644 --- a/hc/api/tests/test_pause.py +++ b/hc/api/tests/test_pause.py @@ -32,3 +32,17 @@ class PauseTestCase(BaseTestCase): HTTP_X_API_KEY="abc") self.assertEqual(r.status_code, 400) + + def test_it_validates_uuid(self): + url = "/api/v1/checks/not-uuid/pause" + r = self.client.post(url, "", content_type="application/json", + HTTP_X_API_KEY="abc") + + self.assertEqual(r.status_code, 400) + + def test_it_handles_missing_check(self): + url = "/api/v1/checks/07c2f548-9850-4b27-af5d-6c9dc157ec02/pause" + r = self.client.post(url, "", content_type="application/json", + HTTP_X_API_KEY="abc") + + self.assertEqual(r.status_code, 400) diff --git a/hc/api/tests/test_update_check.py b/hc/api/tests/test_update_check.py new file mode 100644 index 00000000..89e44b08 --- /dev/null +++ b/hc/api/tests/test_update_check.py @@ -0,0 +1,77 @@ +import json + +from hc.api.models import Channel, Check +from hc.test import BaseTestCase + + +class UpdateCheckTestCase(BaseTestCase): + + def setUp(self): + super(UpdateCheckTestCase, self).setUp() + self.check = Check(user=self.alice) + self.check.save() + + def post(self, code, data): + url = "/api/v1/checks/%s" % code + r = self.client.post(url, json.dumps(data), + content_type="application/json") + + return r + + def test_it_works(self): + r = self.post(self.check.code, { + "api_key": "abc", + "name": "Foo", + "tags": "bar,baz", + "timeout": 3600, + "grace": 60 + }) + + self.assertEqual(r.status_code, 200) + + doc = r.json() + assert "ping_url" in doc + self.assertEqual(doc["name"], "Foo") + self.assertEqual(doc["tags"], "bar,baz") + self.assertEqual(doc["last_ping"], None) + self.assertEqual(doc["n_pings"], 0) + + self.assertTrue("schedule" not in doc) + self.assertTrue("tz" not in doc) + + self.assertEqual(Check.objects.count(), 1) + + self.check.refresh_from_db() + self.assertEqual(self.check.name, "Foo") + self.assertEqual(self.check.tags, "bar,baz") + self.assertEqual(self.check.timeout.total_seconds(), 3600) + self.assertEqual(self.check.grace.total_seconds(), 60) + + def test_it_unassigns_channels(self): + channel = Channel(user=self.alice) + channel.save() + + self.check.assign_all_channels() + + r = self.post(self.check.code, { + "api_key": "abc", + "channels": "" + }) + + self.assertEqual(r.status_code, 200) + check = Check.objects.get() + self.assertEqual(check.channel_set.count(), 0) + + def test_it_requires_post(self): + url = "/api/v1/checks/%s" % self.check.code + r = self.client.get(url, HTTP_X_API_KEY="abc") + self.assertEqual(r.status_code, 405) + + def test_it_handles_invalid_uuid(self): + r = self.post("not-an-uuid", {"api_key": "abc"}) + self.assertEqual(r.status_code, 400) + + def test_it_handles_missing_check(self): + made_up_code = "07c2f548-9850-4b27-af5d-6c9dc157ec02" + r = self.post(made_up_code, {"api_key": "abc"}) + self.assertEqual(r.status_code, 400) diff --git a/hc/api/urls.py b/hc/api/urls.py index b9353f73..6cbf97e5 100644 --- a/hc/api/urls.py +++ b/hc/api/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ url(r'^ping/([\w-]+)/$', views.ping, name="hc-ping-slash"), url(r'^ping/([\w-]+)$', views.ping, name="hc-ping"), url(r'^api/v1/checks/$', views.checks), + url(r'^api/v1/checks/([\w-]+)$', views.update, name="hc-api-update"), url(r'^api/v1/checks/([\w-]+)/pause$', views.pause, name="hc-api-pause"), url(r'^badge/([\w-]+)/([\w-]{8})/([\w-]+).svg$', views.badge, name="hc-badge"), ] diff --git a/hc/api/views.py b/hc/api/views.py index bd5a24c8..d861dcda 100644 --- a/hc/api/views.py +++ b/hc/api/views.py @@ -46,10 +46,30 @@ def ping(request, code): return response -def _create_check(user, spec): - check = Check(user=user) - check.name = spec.get("name", "") - check.tags = spec.get("tags", "") +def _lookup(user, spec): + unique_fields = spec.get("unique", []) + if unique_fields: + existing_checks = Check.objects.filter(user=user) + if "name" in unique_fields: + existing_checks = existing_checks.filter(name=spec.get("name")) + if "tags" in unique_fields: + existing_checks = existing_checks.filter(tags=spec.get("tags")) + if "timeout" in unique_fields: + timeout = td(seconds=spec["timeout"]) + existing_checks = existing_checks.filter(timeout=timeout) + if "grace" in unique_fields: + grace = td(seconds=spec["grace"]) + existing_checks = existing_checks.filter(grace=grace) + + return existing_checks.first() + + +def _update(check, spec): + if "name" in spec: + check.name = spec["name"] + + if "tags" in spec: + check.tags = spec["tags"] if "timeout" in spec and "schedule" not in spec: check.timeout = td(seconds=spec["timeout"]) @@ -63,31 +83,17 @@ def _create_check(user, spec): if "tz" in spec and "schedule" in spec: check.tz = spec["tz"] - unique_fields = spec.get("unique", []) - if unique_fields: - existing_checks = Check.objects.filter(user=user) - if "name" in unique_fields: - existing_checks = existing_checks.filter(name=check.name) - if "tags" in unique_fields: - existing_checks = existing_checks.filter(tags=check.tags) - if "timeout" in unique_fields: - existing_checks = existing_checks.filter(timeout=check.timeout) - if "grace" in unique_fields: - existing_checks = existing_checks.filter(grace=check.grace) - - if existing_checks.count() > 0: - # There might be more than one matching check, return first - first_match = existing_checks.first() - return JsonResponse(first_match.to_dict(), status=200) - check.save() # This needs to be done after saving the check, because of # the M2M relation between checks and channels: - if spec.get("channels") == "*": - check.assign_all_channels() + if "channels" in spec: + if spec["channels"] == "*": + check.assign_all_channels() + elif spec["channels"] == "": + check.channel_set.clear() - return JsonResponse(check.to_dict(), status=201) + return check @csrf_exempt @@ -100,13 +106,39 @@ def checks(request): return JsonResponse(doc) elif request.method == "POST": - return _create_check(request.user, request.json) + created = False + check = _lookup(request.user, request.json) + if check is None: + check = Check(user=request.user) + created = True + + _update(check, request.json) + + return JsonResponse(check.to_dict(), status=201 if created else 200) # If request is neither GET nor POST, return "405 Method not allowed" return HttpResponse(status=405) @csrf_exempt +@uuid_or_400 +@check_api_key +@validate_json(schemas.check) +def update(request, code): + if request.method != "POST": + return HttpResponse(status=405) # method not allowed + + try: + check = Check.objects.get(code=code, user=request.user) + except Check.DoesNotExist: + return HttpResponseBadRequest() + + _update(check, request.json) + return JsonResponse(check.to_dict(), status=200) + + +@csrf_exempt +@uuid_or_400 @check_api_key def pause(request, code): if request.method != "POST": diff --git a/hc/front/management/commands/pygmentize.py b/hc/front/management/commands/pygmentize.py index 0972692e..a52005a7 100644 --- a/hc/front/management/commands/pygmentize.py +++ b/hc/front/management/commands/pygmentize.py @@ -42,6 +42,8 @@ class Command(BaseCommand): _process("list_checks_response", lexers.JsonLexer()) _process("create_check_request_a", lexers.BashLexer()) _process("create_check_request_b", lexers.BashLexer()) + _process("update_check_request_a", lexers.BashLexer()) + _process("update_check_request_b", lexers.BashLexer()) _process("create_check_response", lexers.JsonLexer()) _process("pause_check_request", lexers.BashLexer()) _process("pause_check_response", lexers.JsonLexer()) diff --git a/templates/front/docs_api.html b/templates/front/docs_api.html index c41cbf90..19d3010e 100644 --- a/templates/front/docs_api.html +++ b/templates/front/docs_api.html @@ -12,6 +12,7 @@ This is early days for healtchecks.io REST API. For now, there's API calls to:
@@ -202,9 +203,112 @@ To create a "cron" check, specify the "schedule" and "tz" parameters.+ Updates an existing check. All request parameters are optional. The + check is updated only with the supplied request parameters. + If any parameter is omitted, its value is left unchanged. +
+ +name | +
+ string, optional. +Name for the check. + |
+
---|---|
tags | +
+ string, optional. +A space-delimited list of tags for the check. +Example: +{"tags": "reports staging"}+ |
+
timeout | +
+ number, optional. +A number of seconds, the expected period of this check. +Minimum: 60 (one minute), maximum: 604800 (one week). +Example for 5 minute timeout: +{"kind": "simple", "timeout": 300}+ |
+
grace | +
+ number, optional. +A number of seconds, the grace period for this check. +Minimum: 60 (one minute), maximum: 604800 (one week). + |
+
schedule | +
+ string, optional. +A cron expression defining this check's schedule. +If you specify both "timeout" and "schedule" parameters, + "timeout" will be ignored and "schedule" will be used. +Example for a check running every half-hour: +{"schedule": "0,30 * * * *"}+ |
+
tz | +
+ string, optional. +Server's timezone. This setting only has effect in combination + with the "schedule" paremeter. +Example: +{"tz": "Europe/Riga"}+ |
+
channels | +
+ string, optional. +Set this field to a special value "*" + to automatically assign all existing notification channels. + +Set this field to a special value "" (empty string) + to automatically unassign all notification channels. + + |
+
200 OK | +Returned if the check was successfully updated. | +
---|
Or, alternatively:
+{% include "front/snippets/update_check_request_b.html" %} + + +curl {{ SITE_ROOT }}/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc \
+ --header "X-Api-Key: your-api-key" \
+ --data '{"name": "Backups", "tags": "prod www", "timeout": 3600, "grace": 60}'
+
curl {{ SITE_ROOT }}/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc \
+ --data '{"api_key": "your-api-key", "name": "Backups", "tags": "prod www", "timeout": 3600, "grace": 60}'
+