From b93336a44dd0097b7e9bc5ab457ab0c9a19aed1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Wed, 4 Jan 2017 15:27:59 +0200 Subject: [PATCH] API support for cron syntax --- hc/api/models.py | 7 +- hc/api/schemas.py | 2 + hc/api/tests/test_create_check.py | 51 +++++++++++ hc/api/views.py | 86 ++++++++++--------- hc/lib/jsonschema.py | 10 +++ hc/lib/tests/test_jsonschema.py | 8 ++ templates/front/docs_api.html | 28 +++++- .../front/snippets/list_checks_response.html | 29 ++++--- .../front/snippets/list_checks_response.txt | 29 ++++--- 9 files changed, 180 insertions(+), 70 deletions(-) diff --git a/hc/api/models.py b/hc/api/models.py index d1c019a4..1bfeead9 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -151,12 +151,17 @@ class Check(models.Model): "ping_url": self.url(), "pause_url": settings.SITE_ROOT + pause_rel_url, "tags": self.tags, - "timeout": int(self.timeout.total_seconds()), "grace": int(self.grace.total_seconds()), "n_pings": self.n_pings, "status": self.get_status() } + if self.kind == "simple": + result["timeout"] = int(self.timeout.total_seconds()) + elif self.kind == "cron": + result["schedule"] = self.schedule + result["tz"] = self.tz + if self.last_ping: result["last_ping"] = self.last_ping.isoformat() result["next_ping"] = (self.last_ping + self.timeout).isoformat() diff --git a/hc/api/schemas.py b/hc/api/schemas.py index a57aaaa2..dd4733b6 100644 --- a/hc/api/schemas.py +++ b/hc/api/schemas.py @@ -5,6 +5,8 @@ check = { "tags": {"type": "string", "maxLength": 500}, "timeout": {"type": "number", "minimum": 60, "maximum": 604800}, "grace": {"type": "number", "minimum": 60, "maximum": 604800}, + "schedule": {"type": "string", "format": "cron", "maxLength": 100}, + "tz": {"type": "string", "format": "timezone", "maxLength": 36}, "channels": {"type": "string"}, "unique": { "type": "array", diff --git a/hc/api/tests/test_create_check.py b/hc/api/tests/test_create_check.py index 9c018a42..a9f818e3 100644 --- a/hc/api/tests/test_create_check.py +++ b/hc/api/tests/test_create_check.py @@ -39,6 +39,9 @@ class CreateCheckTestCase(BaseTestCase): 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) check = Check.objects.get() self.assertEqual(check.name, "Foo") @@ -131,3 +134,51 @@ class CreateCheckTestCase(BaseTestCase): "name": "Foo", "unique": "not a list" }, expected_fragment="not an array") + + def test_it_supports_cron_syntax(self): + r = self.post({ + "api_key": "abc", + "schedule": "5 * * * *", + "tz": "Europe/Riga", + "grace": 60 + }) + + self.assertEqual(r.status_code, 201) + + doc = r.json() + self.assertEqual(doc["kind"], "cron") + self.assertEqual(doc["schedule"], "5 * * * *") + self.assertEqual(doc["tz"], "Europe/Riga") + self.assertEqual(doc["grace"], 60) + + self.assertTrue("timeout" not in doc) + + def test_it_validates_cron_expression(self): + r = self.post({ + "api_key": "abc", + "kind": "cron", + "schedule": "not-a-cron-expression", + "tz": "Europe/Riga", + "grace": 60 + }) + + self.assertEqual(r.status_code, 400) + + def test_it_validates_timezone(self): + r = self.post({ + "api_key": "abc", + "kind": "cron", + "schedule": "* * * * *", + "tz": "not-a-timezone", + "grace": 60 + }) + + self.assertEqual(r.status_code, 400) + + def test_it_sets_default_timeout(self): + r = self.post({"api_key": "abc"}) + + self.assertEqual(r.status_code, 201) + + doc = r.json() + self.assertEqual(doc["timeout"], 86400) diff --git a/hc/api/views.py b/hc/api/views.py index 7f57d5ac..bd5a24c8 100644 --- a/hc/api/views.py +++ b/hc/api/views.py @@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt 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, DEFAULT_TIMEOUT, DEFAULT_GRACE +from hc.api.models import Check, Ping from hc.lib.badges import check_signature, get_badge_svg @@ -46,6 +46,50 @@ 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", "") + + if "timeout" in spec and "schedule" not in spec: + check.timeout = td(seconds=spec["timeout"]) + + if "grace" in spec: + check.grace = td(seconds=spec["grace"]) + + if "schedule" in spec: + check.kind = "cron" + check.schedule = spec["schedule"] + 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() + + return JsonResponse(check.to_dict(), status=201) + + @csrf_exempt @check_api_key @validate_json(schemas.check) @@ -56,45 +100,7 @@ def checks(request): return JsonResponse(doc) elif request.method == "POST": - name = str(request.json.get("name", "")) - tags = str(request.json.get("tags", "")) - - timeout = DEFAULT_TIMEOUT - if "timeout" in request.json: - timeout = td(seconds=request.json["timeout"]) - - grace = DEFAULT_GRACE - if "grace" in request.json: - grace = td(seconds=request.json["grace"]) - - unique_fields = request.json.get("unique", []) - if unique_fields: - existing_checks = Check.objects.filter(user=request.user) - if "name" in unique_fields: - existing_checks = existing_checks.filter(name=name) - if "tags" in unique_fields: - existing_checks = existing_checks.filter(tags=tags) - if "timeout" in unique_fields: - existing_checks = existing_checks.filter(timeout=timeout) - if "grace" in unique_fields: - existing_checks = existing_checks.filter(grace=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 = Check(user=request.user, name=name, tags=tags, - timeout=timeout, grace=grace) - - check.save() - - # This needs to be done after saving the check, because of - # the M2M relation between checks and channels: - if request.json.get("channels") == "*": - check.assign_all_channels() - - return JsonResponse(check.to_dict(), status=201) + return _create_check(request.user, request.json) # If request is neither GET nor POST, return "405 Method not allowed" return HttpResponse(status=405) diff --git a/hc/lib/jsonschema.py b/hc/lib/jsonschema.py index 6e7be664..692a6661 100644 --- a/hc/lib/jsonschema.py +++ b/hc/lib/jsonschema.py @@ -4,7 +4,9 @@ Supports only a tiny subset of jsonschema. """ +from croniter import croniter from six import string_types +from pytz import all_timezones class ValidationError(Exception): @@ -17,6 +19,14 @@ def validate(obj, schema, obj_name="value"): raise ValidationError("%s is not a string" % obj_name) if "maxLength" in schema and len(obj) > schema["maxLength"]: raise ValidationError("%s is too long" % obj_name) + if schema.get("format") == "cron": + try: + croniter(obj) + except: + raise ValidationError( + "%s is not a valid cron expression" % obj_name) + if schema.get("format") == "timezone" and obj not in all_timezones: + raise ValidationError("%s is not a valid timezone" % obj_name) elif schema.get("type") == "number": if not isinstance(obj, int): diff --git a/hc/lib/tests/test_jsonschema.py b/hc/lib/tests/test_jsonschema.py index 2ac32402..f0ff72dd 100644 --- a/hc/lib/tests/test_jsonschema.py +++ b/hc/lib/tests/test_jsonschema.py @@ -78,3 +78,11 @@ class JsonSchemaTestCase(TestCase): def test_it_rejects_a_value_not_in_enum(self): with self.assertRaises(ValidationError): validate("baz", {"enum": ["foo", "bar"]}) + + def test_it_checks_cron_format(self): + with self.assertRaises(ValidationError): + validate("x * * * *", {"type": "string", "format": "cron"}) + + def test_it_checks_timezone_format(self): + with self.assertRaises(ValidationError): + validate("X/Y", {"type": "string", "format": "timezone"}) diff --git a/templates/front/docs_api.html b/templates/front/docs_api.html index 4c823814..c41cbf90 100644 --- a/templates/front/docs_api.html +++ b/templates/front/docs_api.html @@ -85,6 +85,11 @@ The response may contain a JSON document with additional data. values if omitted.

+

This API call can be used to create both "simple" and "cron" checks. +To create a "simple" check, specify the "timeout" parameter. +To create a "cron" check, specify the "schedule" and "tz" parameters. +

+

Request Parameters

@@ -110,7 +115,7 @@ The response may contain a JSON document with additional data.

A number of seconds, the expected period of this check.

Minimum: 60 (one minute), maximum: 604800 (one week).

Example for 5 minute timeout:

-
{"timeout": 300}
+
{"kind": "simple", "timeout": 300}
@@ -121,6 +126,27 @@ The response may contain a JSON document with additional data.

Minimum: 60 (one minute), maximum: 604800 (one week).

+ + + + + + + +
schedule +

string, optional, default value: "* * * * *".

+

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, default value: "UTC".

+

Server's timezone. This setting only has effect in combination + with the "schedule" paremeter.

+

Example:

+
{"tz": "Europe/Riga"}
+
channels diff --git a/templates/front/snippets/list_checks_response.html b/templates/front/snippets/list_checks_response.html index d6af5a52..ea6fd3bf 100644 --- a/templates/front/snippets/list_checks_response.html +++ b/templates/front/snippets/list_checks_response.html @@ -1,28 +1,29 @@
{
   "checks": [
     {
+      "last_ping": "2017-01-04T13:24:39.903464+00:00",
+      "ping_url": "{{ PING_ENDPOINT }}662ebe36-ecab-48db-afe3-e20029cb71e6",
+      "next_ping": "2017-01-04T14:24:39.903464+00:00",
       "grace": 900,
-      "last_ping": "2016-07-09T13:58:43.366568+00:00",
-      "n_pings": 1,
       "name": "Api test 1",
-      "next_ping": "2016-07-09T14:58:43.366568+00:00",
-      "pause_url": "{{ SITE_ROOT }}/api/v1/checks/25c55e7c-8092-4d21-ad06-7dacfbb6fc10/pause",
-      "ping_url": "{{ PING_ENDPOINT }}25c55e7c-8092-4d21-ad06-7dacfbb6fc10",
-      "status": "up",
+      "n_pings": 1,
       "tags": "foo",
-      "timeout": 3600
+      "pause_url": "{{ SITE_ROOT }}/api/v1/checks/662ebe36-ecab-48db-afe3-e20029cb71e6/pause",
+      "timeout": 3600,
+      "status": "up"
     },
     {
-      "grace": 60,
       "last_ping": null,
-      "n_pings": 0,
-      "name": "Api test 2",
+      "ping_url": "{{ PING_ENDPOINT }}9d17c61f-5c4f-4cab-b517-11e6b2679ced",
       "next_ping": null,
-      "pause_url": "{{ SITE_ROOT }}/api/v1/checks/7e1b6e61-b16f-4671-bae3-e3233edd1b5e/pause",
-      "ping_url": "{{ PING_ENDPOINT }}7e1b6e61-b16f-4671-bae3-e3233edd1b5e",
-      "status": "new",
+      "grace": 3600,
+      "name": "Api test 2",
+      "n_pings": 0,
       "tags": "bar baz",
-      "timeout": 60
+      "pause_url": "{{ SITE_ROOT }}/api/v1/checks/9d17c61f-5c4f-4cab-b517-11e6b2679ced/pause",
+      "tz": "UTC",
+      "schedule": "0/10 * * * *",
+      "status": "new"
     }
   ]
 }
diff --git a/templates/front/snippets/list_checks_response.txt b/templates/front/snippets/list_checks_response.txt
index 83922761..a89d3961 100644
--- a/templates/front/snippets/list_checks_response.txt
+++ b/templates/front/snippets/list_checks_response.txt
@@ -1,28 +1,29 @@
 {
   "checks": [
     {
+      "last_ping": "2017-01-04T13:24:39.903464+00:00",
+      "ping_url": "PING_ENDPOINT662ebe36-ecab-48db-afe3-e20029cb71e6",
+      "next_ping": "2017-01-04T14:24:39.903464+00:00",
       "grace": 900,
-      "last_ping": "2016-07-09T13:58:43.366568+00:00",
-      "n_pings": 1,
       "name": "Api test 1",
-      "next_ping": "2016-07-09T14:58:43.366568+00:00",
-      "pause_url": "SITE_ROOT/api/v1/checks/25c55e7c-8092-4d21-ad06-7dacfbb6fc10/pause",
-      "ping_url": "PING_ENDPOINT25c55e7c-8092-4d21-ad06-7dacfbb6fc10",
-      "status": "up",
+      "n_pings": 1,
       "tags": "foo",
-      "timeout": 3600
+      "pause_url": "SITE_ROOT/api/v1/checks/662ebe36-ecab-48db-afe3-e20029cb71e6/pause",
+      "timeout": 3600,
+      "status": "up"
     },
     {
-      "grace": 60,
       "last_ping": null,
-      "n_pings": 0,
-      "name": "Api test 2",
+      "ping_url": "PING_ENDPOINT9d17c61f-5c4f-4cab-b517-11e6b2679ced",
       "next_ping": null,
-      "pause_url": "SITE_ROOT/api/v1/checks/7e1b6e61-b16f-4671-bae3-e3233edd1b5e/pause",
-      "ping_url": "PING_ENDPOINT7e1b6e61-b16f-4671-bae3-e3233edd1b5e",
-      "status": "new",
+      "grace": 3600,
+      "name": "Api test 2",
+      "n_pings": 0,
       "tags": "bar baz",
-      "timeout": 60
+      "pause_url": "SITE_ROOT/api/v1/checks/9d17c61f-5c4f-4cab-b517-11e6b2679ced/pause",
+      "tz": "UTC",
+      "schedule": "0/10 * * * *",
+      "status": "new"
     }
   ]
 }
\ No newline at end of file