diff --git a/CHANGELOG.md b/CHANGELOG.md index 5932928d..139a88aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. - Paused ping handling can be controlled via API (#376) - Add "Get a list of checks's logged pings" API call (#371) - The /api/v1/checks/ endpoint now accepts either UUID or `unique_key` (#370) +- Added /api/v1/checks/uuid/flips/ endpoint (#349) ### Bug Fixes diff --git a/hc/api/forms.py b/hc/api/forms.py new file mode 100644 index 00000000..1d983b3f --- /dev/null +++ b/hc/api/forms.py @@ -0,0 +1,28 @@ +from datetime import datetime as dt + +from django import forms +from django.core.exceptions import ValidationError +import pytz + + +class TimestampField(forms.Field): + def to_python(self, value): + if value is None: + return None + + try: + value_int = int(value) + except ValueError: + raise ValidationError(message="Must be an integer") + + # 10000000000 is year 2286 (a sanity check) + if value_int < 0 or value_int > 10000000000: + raise ValidationError(message="Out of bounds") + + return dt.fromtimestamp(value_int, pytz.UTC) + + +class FlipsFiltersForm(forms.Form): + start = TimestampField(required=False) + end = TimestampField(required=False) + seconds = forms.IntegerField(required=False, min_value=0, max_value=31536000) diff --git a/hc/api/models.py b/hc/api/models.py index 3962a1ad..ba5aa13f 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -760,6 +760,12 @@ class Flip(models.Model): ) ] + def to_dict(self): + return { + "timestamp": isostring(self.created), + "up": 1 if self.new_status == "up" else 0, + } + def send_alerts(self): if self.new_status == "up" and self.old_status in ("new", "paused"): # Don't send alerts on new->up and paused->up transitions diff --git a/hc/api/tests/test_get_flips.py b/hc/api/tests/test_get_flips.py new file mode 100644 index 00000000..ac4b2ea6 --- /dev/null +++ b/hc/api/tests/test_get_flips.py @@ -0,0 +1,77 @@ +from datetime import timedelta as td +from datetime import datetime as dt +from datetime import timezone + +from hc.api.models import Check, Flip +from hc.test import BaseTestCase + + +class GetFlipsTestCase(BaseTestCase): + def setUp(self): + super(GetFlipsTestCase, self).setUp() + + self.a1 = Check(project=self.project, name="Alice 1") + self.a1.timeout = td(seconds=3600) + self.a1.grace = td(seconds=900) + self.a1.n_pings = 0 + self.a1.status = "new" + self.a1.tags = "a1-tag a1-additional-tag" + self.a1.desc = "This is description" + self.a1.save() + + Flip.objects.create( + owner=self.a1, + created=dt(2020, 6, 1, 12, 24, 32, 123000, tzinfo=timezone.utc), + old_status="new", + new_status="up", + ) + + self.url = "/api/v1/checks/%s/flips/" % self.a1.code + + def get(self, api_key="X" * 32, qs=""): + return self.client.get(self.url + qs, HTTP_X_API_KEY=api_key) + + def test_it_works(self): + + r = self.get() + self.assertEqual(r.status_code, 200) + self.assertEqual(r["Access-Control-Allow-Origin"], "*") + + doc = r.json() + self.assertEqual(len(doc["flips"]), 1) + + flip = doc["flips"][0] + # Microseconds (123000) should be stripped out + self.assertEqual(flip["timestamp"], "2020-06-01T12:24:32+00:00") + self.assertEqual(flip["up"], 1) + + def test_readonly_key_is_allowed(self): + self.project.api_key_readonly = "R" * 32 + self.project.save() + + r = self.get(api_key=self.project.api_key_readonly) + self.assertEqual(r.status_code, 200) + + def test_it_rejects_post(self): + r = self.client.post(self.url, HTTP_X_API_KEY="X" * 32) + self.assertEqual(r.status_code, 405) + + def test_it_rejects_non_integer_start(self): + r = self.get(qs="?start=abc") + self.assertEqual(r.status_code, 400) + + def test_it_rejects_negative_start(self): + r = self.get(qs="?start=-123") + self.assertEqual(r.status_code, 400) + + def test_it_rejects_huge_start(self): + r = self.get(qs="?start=12345678901234567890") + self.assertEqual(r.status_code, 400) + + def test_it_rejects_negative_seconds(self): + r = self.get(qs="?seconds=-123") + self.assertEqual(r.status_code, 400) + + def test_it_rejects_huge_seconds(self): + r = self.get(qs="?seconds=12345678901234567890") + self.assertEqual(r.status_code, 400) diff --git a/hc/api/urls.py b/hc/api/urls.py index 422ee154..09be5cfb 100644 --- a/hc/api/urls.py +++ b/hc/api/urls.py @@ -38,6 +38,8 @@ urlpatterns = [ path("api/v1/checks//pause", views.pause, name="hc-api-pause"), path("api/v1/notifications//bounce", views.bounce, name="hc-api-bounce"), path("api/v1/checks//pings/", views.pings, name="hc-api-pings"), + path("api/v1/checks//flips/", views.flips_by_uuid, name="hc-api-flips"), + path("api/v1/checks//flips/", views.flips_by_unique_key), path("api/v1/channels/", views.channels), path( "badge///.", diff --git a/hc/api/views.py b/hc/api/views.py index 9c876fc2..478f4fd2 100644 --- a/hc/api/views.py +++ b/hc/api/views.py @@ -8,6 +8,7 @@ from django.http import ( HttpResponse, HttpResponseForbidden, HttpResponseNotFound, + HttpResponseBadRequest, JsonResponse, ) from django.shortcuts import get_object_or_404 @@ -19,6 +20,7 @@ from django.views.decorators.http import require_POST from hc.accounts.models import Profile from hc.api import schemas from hc.api.decorators import authorize, authorize_read, cors, validate_json +from hc.api.forms import FlipsFiltersForm from hc.api.models import MAX_DELTA, Flip, Channel, Check, Notification, Ping from hc.lib.badges import check_signature, get_badge_svg @@ -188,7 +190,6 @@ def get_check(request, code): check = get_object_or_404(Check, code=code) if check.project_id != request.project.id: return HttpResponseForbidden() - return JsonResponse(check.to_dict(readonly=request.readonly)) @@ -292,6 +293,50 @@ def pings(request, code): return JsonResponse({"pings": dicts}) +def flips(request, check): + if check.project_id != request.project.id: + return HttpResponseForbidden() + + form = FlipsFiltersForm(request.GET) + if not form.is_valid(): + return HttpResponseBadRequest() + + flips = Flip.objects.filter(owner=check).order_by("-id") + + if form.cleaned_data["start"]: + flips = flips.filter(created__gte=form.cleaned_data["start"]) + + if form.cleaned_data["end"]: + flips = flips.filter(created__lt=form.cleaned_data["end"]) + + if form.cleaned_data["seconds"]: + threshold = timezone.now() - td(seconds=form.cleaned_data["seconds"]) + flips = flips.filter(created__gte=threshold) + + return JsonResponse({"flips": [flip.to_dict() for flip in flips]}) + + +@cors("GET") +@csrf_exempt +@validate_json() +@authorize_read +def flips_by_uuid(request, code): + check = get_object_or_404(Check, code=code) + return flips(request, check) + + +@cors("GET") +@csrf_exempt +@validate_json() +@authorize_read +def flips_by_unique_key(request, unique_key): + checks = Check.objects.filter(project=request.project.id) + for check in checks: + if check.unique_key == unique_key: + return flips(request, check) + return HttpResponseNotFound() + + @never_cache @cors("GET") def badge(request, badge_key, signature, tag, fmt="svg"): diff --git a/templates/docs/api.html b/templates/docs/api.html index 202bcf14..3c3d2554 100644 --- a/templates/docs/api.html +++ b/templates/docs/api.html @@ -43,6 +43,10 @@ checks in user's account.

GET SITE_ROOT/api/v1/checks/<uuid>/pings/ +Get a list of check's status changes +GET SITE_ROOT/api/v1/checks/<uuid>/flips/ + + Get a list of existing integrations GET SITE_ROOT/api/v1/channels/ @@ -679,6 +683,69 @@ number of returned pings depends on account's billing plan: 100 for free account +

Get a list of check's status changes

+

GET SITE_ROOT/api/v1/checks/<uuid>/flips/
+GET SITE_ROOT/api/v1/checks/<unique_key>/flips/

+

Returns a list of "flips" this check has experienced. A flip is a change of status +(from "down" to "up", or from "up" to "down").

+

Query String Parameters

+
+
seconds=<value>
+
+

Returns the flips from the last value seconds

+

Example:

+

SITE_ROOT/api/v1/checks/<uuid|unique_key>/flips/?seconds=3600

+
+
start=<value>
+
+

Returns flips that are newer than the specified UNIX timestamp.

+

Example:

+

SITE_ROOT/api/v1/checks/<uuid|unique_key>/flips/?start=1592214380

+
+
end=<value>
+
+

Returns flips that are older than the specified UNIX timestamp.

+

Example:

+

SITE_ROOT/api/v1/checks/<uuid|unique_key>/flips/?end=1592217980

+
+
+

Response Codes

+
+
200 OK
+
The request succeeded.
+
400 Bad Request
+
Invalid query parameters.
+
401 Unauthorized
+
The API key is either missing or invalid.
+
403 Forbidden
+
Access denied, wrong API key.
+
404 Not Found
+
The specified check does not exist.
+
+

Example Request

+
curl SITE_ROOT/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/flips/ \
+    --header "X-Api-Key: your-api-key"
+
+ + +

Example Response

+
[
+    {
+      "timestamp": "2020-03-23T10:18:23+00:00",
+      "up": 1
+    },
+    {
+      "timestamp": "2020-03-23T10:17:15+00:00",
+      "up": 0
+    },
+    {
+      "timestamp": "2020-03-23T10:16:18+00:00",
+      "up": 1
+    }
+]
+
+ +

Get a List of Existing Integrations

GET SITE_ROOT/api/v1/channels/

Returns a list of integrations belonging to the project.

diff --git a/templates/docs/api.md b/templates/docs/api.md index dd1ca5b5..631c8bec 100644 --- a/templates/docs/api.md +++ b/templates/docs/api.md @@ -14,7 +14,8 @@ Endpoint Name | Endpoint Address [Update an existing check](#update-check) | `POST SITE_ROOT/api/v1/checks/` [Pause monitoring of a check](#pause-check) | `POST SITE_ROOT/api/v1/checks//pause` [Delete check](#delete-check) | `DELETE SITE_ROOT/api/v1/checks/` -[Get a list of check's logged pings](#list-pings) | `GET SITE_ROOT/api/v1/checks//pings/` +[Get a list of check's logged pings](#list-pings) | `GET SITE_ROOT/api/v1/checks//pings/` +[Get a list of check's status changes](#list-flips) | `GET SITE_ROOT/api/v1/checks//flips/` [Get a list of existing integrations](#list-channels) | `GET SITE_ROOT/api/v1/channels/` ## Authentication @@ -746,6 +747,81 @@ curl SITE_ROOT/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/pings/ \ ``` +## Get a list of check's status changes {: #list-flips .rule } + +`GET SITE_ROOT/api/v1/checks//flips/`
+`GET SITE_ROOT/api/v1/checks//flips/` + +Returns a list of "flips" this check has experienced. A flip is a change of status +(from "down" to "up", or from "up" to "down"). + +### Query String Parameters + +seconds=<value> +: Returns the flips from the last `value` seconds + + Example: + + `SITE_ROOT/api/v1/checks//flips/?seconds=3600` + +start=<value> +: Returns flips that are newer than the specified UNIX timestamp. + + Example: + + `SITE_ROOT/api/v1/checks//flips/?start=1592214380` + +end=<value> +: Returns flips that are older than the specified UNIX timestamp. + + Example: + + `SITE_ROOT/api/v1/checks//flips/?end=1592217980` + + +### Response Codes + +200 OK +: The request succeeded. + +400 Bad Request +: Invalid query parameters. + +401 Unauthorized +: The API key is either missing or invalid. + +403 Forbidden +: Access denied, wrong API key. + +404 Not Found +: The specified check does not exist. + +### Example Request + +```bash +curl SITE_ROOT/api/v1/checks/f618072a-7bde-4eee-af63-71a77c5723bc/flips/ \ + --header "X-Api-Key: your-api-key" +``` + +### Example Response + +```json +[ + { + "timestamp": "2020-03-23T10:18:23+00:00", + "up": 1 + }, + { + "timestamp": "2020-03-23T10:17:15+00:00", + "up": 0 + }, + { + "timestamp": "2020-03-23T10:16:18+00:00", + "up": 1 + } +] +``` + ## Get a List of Existing Integrations {: #list-channels .rule } `GET SITE_ROOT/api/v1/channels/`