From 5d2edfa4a04099cd26b57938f36683f1b5f4ddf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Tue, 16 Feb 2016 23:41:40 +0200 Subject: [PATCH] First stab at API, POST /api/v1/checks --- hc/accounts/views.py | 5 +-- hc/api/decorators.py | 63 ++++++++++++++++++++++++++++- hc/api/schemas.py | 8 ++++ hc/api/tests/test_create_check.py | 67 +++++++++++++++++++++++++++++++ hc/api/tests/test_status.py | 17 -------- hc/api/urls.py | 2 +- hc/api/views.py | 40 +++++++++--------- 7 files changed, 161 insertions(+), 41 deletions(-) create mode 100644 hc/api/schemas.py create mode 100644 hc/api/tests/test_create_check.py delete mode 100644 hc/api/tests/test_status.py diff --git a/hc/accounts/views.py b/hc/accounts/views.py index 20c0613a..76f4edbe 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -174,10 +174,7 @@ def set_password(request, token): messages.info(request, "Your password has been set!") return redirect("hc-profile") - ctx = { - } - - return render(request, "accounts/set_password.html", ctx) + return render(request, "accounts/set_password.html", {}) def unsubscribe_reports(request, username): diff --git a/hc/api/decorators.py b/hc/api/decorators.py index 6f737be9..8deef9fb 100644 --- a/hc/api/decorators.py +++ b/hc/api/decorators.py @@ -1,7 +1,9 @@ +import json import uuid from functools import wraps -from django.http import HttpResponseBadRequest +from django.contrib.auth.models import User +from django.http import HttpResponseBadRequest, JsonResponse def uuid_or_400(f): @@ -14,3 +16,62 @@ def uuid_or_400(f): return f(request, *args, **kwds) return wrapper + + +def make_error(msg): + return JsonResponse({"error": msg}, status=400) + + +def check_api_key(f): + @wraps(f) + def wrapper(request, *args, **kwds): + try: + data = json.loads(request.body.decode("utf-8")) + except ValueError: + return make_error("could not parse request body") + + api_key = str(data.get("api_key", "")) + if api_key == "": + return make_error("wrong api_key") + + try: + user = User.objects.get(profile__api_key=api_key) + except User.DoesNotExist: + return make_error("wrong api_key") + + request.json = data + request.user = user + return f(request, *args, **kwds) + + return wrapper + + +def validate_json(schema): + """ Validate request.json contents against `schema`. + + Supports a tiny subset of JSON schema spec. + + """ + + def decorator(f): + @wraps(f) + def wrapper(request, *args, **kwds): + for key, spec in schema["properties"].items(): + if key not in request.json: + continue + + value = request.json[key] + if spec["type"] == "string": + if not isinstance(value, str): + return make_error("%s is not a string" % key) + elif spec["type"] == "number": + if not isinstance(value, int): + return make_error("%s is not a number" % key) + if "minimum" in spec and value < spec["minimum"]: + return make_error("%s is too small" % key) + if "maximum" in spec and value > spec["maximum"]: + return make_error("%s is too large" % key) + + return f(request, *args, **kwds) + return wrapper + return decorator diff --git a/hc/api/schemas.py b/hc/api/schemas.py new file mode 100644 index 00000000..55337b8b --- /dev/null +++ b/hc/api/schemas.py @@ -0,0 +1,8 @@ +check = { + "properties": { + "name": {"type": "string"}, + "tags": {"type": "string"}, + "timeout": {"type": "number", "minimum": 60, "maximum": 604800}, + "grace": {"type": "number", "minimum": 60, "maximum": 604800} + } +} diff --git a/hc/api/tests/test_create_check.py b/hc/api/tests/test_create_check.py new file mode 100644 index 00000000..84bd66e1 --- /dev/null +++ b/hc/api/tests/test_create_check.py @@ -0,0 +1,67 @@ +import json + +from hc.api.models import Check +from hc.test import BaseTestCase +from hc.accounts.models import Profile + + +class CreateCheckTestCase(BaseTestCase): + + def setUp(self): + super(CreateCheckTestCase, self).setUp() + self.profile = Profile(user=self.alice, api_key="abc") + self.profile.save() + + def post(self, url, data): + return self.client.post(url, json.dumps(data), + content_type="application/json") + + def test_it_works(self): + r = self.post("/api/v1/checks/", { + "api_key": "abc", + "name": "Foo", + "tags": "bar,baz", + "timeout": 3600, + "grace": 60 + }) + + self.assertEqual(r.status_code, 201) + self.assertTrue("ping_url" in r.json()) + + self.assertEqual(Check.objects.count(), 1) + check = Check.objects.get() + self.assertEqual(check.name, "Foo") + self.assertEqual(check.tags, "bar,baz") + self.assertEqual(check.timeout.total_seconds(), 3600) + self.assertEqual(check.grace.total_seconds(), 60) + + def test_it_handles_missing_request_body(self): + r = self.client.post("/api/v1/checks/", + content_type="application/json") + self.assertEqual(r.status_code, 400) + self.assertEqual(r.json()["error"], "wrong api_key") + + def test_it_rejects_wrong_api_key(self): + r = self.post("/api/v1/checks/", {"api_key": "wrong"}) + self.assertEqual(r.json()["error"], "wrong api_key") + + def test_it_handles_invalid_json(self): + r = self.client.post("/api/v1/checks/", "this is not json", + content_type="application/json") + self.assertEqual(r.json()["error"], "could not parse request body") + + def test_it_reject_small_timeout(self): + r = self.post("/api/v1/checks/", {"api_key": "abc", "timeout": 0}) + self.assertEqual(r.json()["error"], "timeout is too small") + + def test_it_rejects_large_timeout(self): + r = self.post("/api/v1/checks/", {"api_key": "abc", "timeout": 604801}) + self.assertEqual(r.json()["error"], "timeout is too large") + + def test_it_rejects_non_number_timeout(self): + r = self.post("/api/v1/checks/", {"api_key": "abc", "timeout": "oops"}) + self.assertEqual(r.json()["error"], "timeout is not a number") + + def test_it_rejects_non_string_name(self): + r = self.post("/api/v1/checks/", {"api_key": "abc", "name": False}) + self.assertEqual(r.json()["error"], "name is not a string") diff --git a/hc/api/tests/test_status.py b/hc/api/tests/test_status.py deleted file mode 100644 index 21292da6..00000000 --- a/hc/api/tests/test_status.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.test import TestCase - -from hc.api.models import Check - - -class StatusTestCase(TestCase): - - def test_it_works(self): - check = Check() - check.save() - - r = self.client.get("/status/%s/" % check.code) - self.assertContains(r, "last_ping", status_code=200) - - def test_it_handles_bad_uuid(self): - r = self.client.get("/status/not-uuid/") - assert r.status_code == 400 diff --git a/hc/api/urls.py b/hc/api/urls.py index ab8c5842..2506c48f 100644 --- a/hc/api/urls.py +++ b/hc/api/urls.py @@ -5,6 +5,6 @@ from hc.api import views urlpatterns = [ url(r'^ping/([\w-]+)/$', views.ping, name="hc-ping-slash"), url(r'^ping/([\w-]+)$', views.ping, name="hc-ping"), - url(r'^status/([\w-]+)/$', views.status, name="hc-status"), url(r'^handle_email/$', views.handle_email, name="hc-handle-email"), + url(r'^api/v1/checks/$', views.create_check), ] diff --git a/hc/api/views.py b/hc/api/views.py index e6eed57f..c94c6349 100644 --- a/hc/api/views.py +++ b/hc/api/views.py @@ -1,13 +1,14 @@ +from datetime import timedelta as td import json -from django.contrib.humanize.templatetags.humanize import naturaltime from django.db.models import F -from django.http import HttpResponse, HttpResponseBadRequest +from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from django.utils import timezone from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt -from hc.api.decorators import uuid_or_400 +from hc.api import schemas +from hc.api.decorators import check_api_key, uuid_or_400, validate_json from hc.api.models import Check, Ping @@ -75,22 +76,25 @@ def handle_email(request): return response -@uuid_or_400 -def status(request, code): - response = { - "last_ping": None, - "last_ping_human": None, - "secs_to_alert": None - } +@csrf_exempt +@check_api_key +@validate_json(schemas.check) +def create_check(request): + if request.method != "POST": + return HttpResponse(status=405) - check = Check.objects.get(code=code) + check = Check(user=request.user) + check.name = str(request.json.get("name", "")) + check.tags = str(request.json.get("tags", "")) + if "timeout" in request.json: + check.timeout = td(seconds=request.json["timeout"]) + if "grace" in request.json: + check.grace = td(seconds=request.json["grace"]) - if check.last_ping and check.alert_after: - response["last_ping"] = check.last_ping.isoformat() - response["last_ping_human"] = naturaltime(check.last_ping) + check.save() - duration = check.alert_after - timezone.now() - response["secs_to_alert"] = int(duration.total_seconds()) + response = { + "ping_url": check.url() + } - return HttpResponse(json.dumps(response), - content_type="application/javascript") + return JsonResponse(response, status=201)