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.

Example Response

{% include "front/snippets/create_check_response.html" %} - + +

Update an existing check

+
+ +
POST {{ SITE_ROOT }}/api/v1/checks/<code>
+ + + +

+ 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. +

+ +

Request Parameters

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
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. +

+
+ +

Response Codes

+ + + + + +
200 OKReturned if the check was successfully updated.
+ +

Example Request

+{% include "front/snippets/update_check_request_a.html" %} +
+

Or, alternatively:

+{% include "front/snippets/update_check_request_b.html" %} + + +

Example Response

+{% include "front/snippets/create_check_response.html" %} + +

Pause Monitoring of a Check

diff --git a/templates/front/snippets/create_check_response.html b/templates/front/snippets/create_check_response.html index 1016fa10..4dbc6900 100644 --- a/templates/front/snippets/create_check_response.html +++ b/templates/front/snippets/create_check_response.html @@ -8,6 +8,7 @@ "ping_url": "{{ PING_ENDPOINT }}f618072a-7bde-4eee-af63-71a77c5723bc", "status": "new", "tags": "prod www", - "timeout": 3600 + "timeout": 3600, + "update_url": "{{ SITE_ROOT }}/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc", } diff --git a/templates/front/snippets/create_check_response.txt b/templates/front/snippets/create_check_response.txt index 092b3f0e..4e9bafa4 100644 --- a/templates/front/snippets/create_check_response.txt +++ b/templates/front/snippets/create_check_response.txt @@ -8,5 +8,6 @@ "ping_url": "PING_ENDPOINTf618072a-7bde-4eee-af63-71a77c5723bc", "status": "new", "tags": "prod www", - "timeout": 3600 + "timeout": 3600, + "update_url": "SITE_ROOT/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc", } \ No newline at end of file diff --git a/templates/front/snippets/list_checks_response.html b/templates/front/snippets/list_checks_response.html index ea6fd3bf..22093448 100644 --- a/templates/front/snippets/list_checks_response.html +++ b/templates/front/snippets/list_checks_response.html @@ -10,7 +10,8 @@ "tags": "foo", "pause_url": "{{ SITE_ROOT }}/api/v1/checks/662ebe36-ecab-48db-afe3-e20029cb71e6/pause", "timeout": 3600, - "status": "up" + "status": "up", + "update_url": "{{ SITE_ROOT }}/api/v1/checks/662ebe36-ecab-48db-afe3-e20029cb71e6" }, { "last_ping": null, @@ -23,7 +24,8 @@ "pause_url": "{{ SITE_ROOT }}/api/v1/checks/9d17c61f-5c4f-4cab-b517-11e6b2679ced/pause", "tz": "UTC", "schedule": "0/10 * * * *", - "status": "new" + "status": "new", + "update_url": "{{ SITE_ROOT }}/api/v1/checks/9d17c61f-5c4f-4cab-b517-11e6b2679ced" } ] } diff --git a/templates/front/snippets/list_checks_response.txt b/templates/front/snippets/list_checks_response.txt index a89d3961..320cf213 100644 --- a/templates/front/snippets/list_checks_response.txt +++ b/templates/front/snippets/list_checks_response.txt @@ -10,7 +10,8 @@ "tags": "foo", "pause_url": "SITE_ROOT/api/v1/checks/662ebe36-ecab-48db-afe3-e20029cb71e6/pause", "timeout": 3600, - "status": "up" + "status": "up", + "update_url": "SITE_ROOT/api/v1/checks/662ebe36-ecab-48db-afe3-e20029cb71e6" }, { "last_ping": null, @@ -23,7 +24,8 @@ "pause_url": "SITE_ROOT/api/v1/checks/9d17c61f-5c4f-4cab-b517-11e6b2679ced/pause", "tz": "UTC", "schedule": "0/10 * * * *", - "status": "new" + "status": "new", + "update_url": "SITE_ROOT/api/v1/checks/9d17c61f-5c4f-4cab-b517-11e6b2679ced" } ] } \ No newline at end of file diff --git a/templates/front/snippets/pause_check_response.html b/templates/front/snippets/pause_check_response.html index 134707ac..6a0301fc 100644 --- a/templates/front/snippets/pause_check_response.html +++ b/templates/front/snippets/pause_check_response.html @@ -8,6 +8,7 @@ "ping_url": "{{ PING_ENDPOINT }}f618072a-7bde-4eee-af63-71a77c5723bc", "status": "paused", "tags": "prod www", - "timeout": 3600 + "timeout": 3600, + "update_url": "{{ SITE_ROOT }}/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc" } diff --git a/templates/front/snippets/pause_check_response.txt b/templates/front/snippets/pause_check_response.txt index ba97e094..70039d68 100644 --- a/templates/front/snippets/pause_check_response.txt +++ b/templates/front/snippets/pause_check_response.txt @@ -8,5 +8,6 @@ "ping_url": "PING_ENDPOINTf618072a-7bde-4eee-af63-71a77c5723bc", "status": "paused", "tags": "prod www", - "timeout": 3600 + "timeout": 3600, + "update_url": "SITE_ROOT/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc" } \ No newline at end of file diff --git a/templates/front/snippets/update_check_request_a.html b/templates/front/snippets/update_check_request_a.html new file mode 100644 index 00000000..51ea37d0 --- /dev/null +++ b/templates/front/snippets/update_check_request_a.html @@ -0,0 +1,4 @@ +
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}'
+
diff --git a/templates/front/snippets/update_check_request_a.txt b/templates/front/snippets/update_check_request_a.txt new file mode 100644 index 00000000..34907d2d --- /dev/null +++ b/templates/front/snippets/update_check_request_a.txt @@ -0,0 +1,3 @@ +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}' diff --git a/templates/front/snippets/update_check_request_b.html b/templates/front/snippets/update_check_request_b.html new file mode 100644 index 00000000..70368b87 --- /dev/null +++ b/templates/front/snippets/update_check_request_b.html @@ -0,0 +1,3 @@ +
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}'
+
diff --git a/templates/front/snippets/update_check_request_b.txt b/templates/front/snippets/update_check_request_b.txt new file mode 100644 index 00000000..99e977aa --- /dev/null +++ b/templates/front/snippets/update_check_request_b.txt @@ -0,0 +1,2 @@ +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}'