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
|