0
0
Fork 0
mirror of https://github.com/healthchecks/healthchecks.git synced 2025-04-08 14:40:05 +00:00

Add /api/v3/ (adds ability to set slug when creating or updating checks)

This commit is contained in:
Pēteris Caune 2023-06-14 16:52:45 +03:00
parent d7d9702ee0
commit 4ccee09f73
No known key found for this signature in database
GPG key ID: E28D7679E9A9EDE2
16 changed files with 105 additions and 21 deletions

View file

@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
- Configure logging to log unhandled exceptions to console even when DEBUG=False (#835)
- Make hc.lib.emails raise exceptions when EMAIL_ settings are not set
- Decouple check's name from slug, allow users to set hand-picked slugs
- Add /api/v3/ (adds ability to specify slug when creating or updating checks)
## v2.9.2 - 2023-06-05

View file

@ -79,7 +79,8 @@ def _make_user(email, tz=None, with_project=True):
project.save()
check = Check(project=project)
check.set_name_slug("My First Check")
check.name = "My First Check"
check.slug = "my-first-check"
check.save()
channel = Channel(project=project)

View file

@ -33,7 +33,11 @@ def authorize(f):
return error("wrong api key", 401)
request.readonly = False
request.v = 2 if request.path_info.startswith("/api/v2/") else 1
request.v = 1
if request.path_info.startswith("/api/v2/"):
request.v = 2
elif request.path_info.startswith("/api/v3/"):
request.v = 3
return f(request, *args, **kwds)
return wrapper

View file

@ -19,7 +19,6 @@ from django.core.signing import TimestampSigner
from django.db import models, transaction
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.text import slugify
from django.utils.timezone import now
from hc.accounts.models import Project
@ -233,10 +232,6 @@ class Check(models.Model):
return self.last_duration
return None
def set_name_slug(self, name: str) -> None:
self.name = name
self.slug = slugify(name)
def get_grace_start(self, *, with_started: bool = True) -> datetime | None:
"""Return the datetime when the grace period starts.

View file

@ -4,6 +4,7 @@ check = {
"type": "object",
"properties": {
"name": {"type": "string", "maxLength": 100},
"slug": {"type": "string", "pattern": "^[a-zA-Z0-9-_]*$"},
"desc": {"type": "string"},
"tags": {"type": "string", "maxLength": 500},
"timeout": {"type": "number", "minimum": 60, "maximum": 31536000},
@ -22,7 +23,7 @@ check = {
"filter_body": {"type": "boolean"},
"unique": {
"type": "array",
"items": {"enum": ["name", "tags", "timeout", "grace"]},
"items": {"enum": ["name", "slug", "tags", "timeout", "grace"]},
},
},
}

View file

@ -172,6 +172,21 @@ class CreateCheckTestCase(BaseTestCase):
check.refresh_from_db()
self.assertEqual(check.tags, "bar")
def test_it_supports_unique_slug(self):
check = Check.objects.create(project=self.project, slug="foo")
r = self.post({"slug": "foo", "tags": "bar", "unique": ["slug"]})
# Expect 200 instead of 201
self.assertEqual(r.status_code, 200)
# And there should be only one check in the database:
self.assertEqual(Check.objects.count(), 1)
# The tags field should have a value now:
check.refresh_from_db()
self.assertEqual(check.tags, "bar")
def test_it_supports_unique_tags(self):
Check.objects.create(project=self.project, tags="foo")
@ -205,6 +220,18 @@ class CreateCheckTestCase(BaseTestCase):
# And there should be only one check in the database:
self.assertEqual(Check.objects.count(), 1)
def test_it_handles_empty_unique_parameter(self):
check = Check.objects.create(project=self.project)
r = self.post({"name": "Hello", "unique": []})
# Expect 201
self.assertEqual(r.status_code, 201)
# The pre-existing check should be unchanged:
check.refresh_from_db()
self.assertEqual(check.name, "")
def test_it_handles_missing_request_body(self):
r = self.client.post(self.URL, content_type="application/json")
self.assertEqual(r.status_code, 401)
@ -343,3 +370,25 @@ class CreateCheckTestCase(BaseTestCase):
doc = r.json()
self.assertEqual(doc["status"], "new")
self.assertTrue(doc["started"])
def test_v3_saves_slug(self):
r = self.post({"name": "Foo", "slug": "custom-slug"}, v=3)
self.assertEqual(r.status_code, 201)
check = Check.objects.get()
self.assertEqual(check.name, "Foo")
self.assertEqual(check.slug, "custom-slug")
def test_v3_does_not_autogenerate_slug(self):
r = self.post({"name": "Foo"}, v=3)
self.assertEqual(r.status_code, 201)
check = Check.objects.get()
self.assertEqual(check.slug, "")
def test_it_handles_invalid_slug(self):
r = self.post({"name": "Foo", "slug": "Hey!"}, v=3)
self.assertEqual(r.status_code, 400)
self.assertEqual(
r.json()["error"], "json validation error: slug does not match pattern"
)

View file

@ -15,7 +15,8 @@ class GetCheckTestCase(BaseTestCase):
self.now = now().replace(microsecond=0)
self.a1 = Check(project=self.project)
self.a1.set_name_slug("Alice 1")
self.a1.name = "Alice 1"
self.a1.slug = "alice-1-custom-slug"
self.a1.timeout = td(seconds=3600)
self.a1.grace = td(seconds=900)
self.a1.n_pings = 0
@ -43,7 +44,7 @@ class GetCheckTestCase(BaseTestCase):
doc = r.json()
self.assertEqual(len(doc), 25)
self.assertEqual(doc["slug"], "alice-1")
self.assertEqual(doc["slug"], "alice-1-custom-slug")
self.assertEqual(doc["timeout"], 3600)
self.assertEqual(doc["grace"], 900)
self.assertEqual(doc["ping_url"], self.a1.url())

View file

@ -61,7 +61,7 @@ class NotifyTestCase(BaseTestCase):
self.assertEqual(payload["To"], "+1234567890")
n = Notification.objects.get()
callback_path = f"/api/v2/notifications/{n.code}/status"
callback_path = f"/api/v3/notifications/{n.code}/status"
self.assertTrue(payload["StatusCallback"].endswith(callback_path))
@patch("hc.api.transports.curl.request")

View file

@ -44,7 +44,7 @@ class NotifySmsTestCase(BaseTestCase):
self.assertIn("is DOWN", payload["Body"])
n = Notification.objects.get()
callback_path = f"/api/v2/notifications/{n.code}/status"
callback_path = f"/api/v3/notifications/{n.code}/status"
self.assertTrue(payload["StatusCallback"].endswith(callback_path))
# sent SMS counter should go up

View file

@ -41,7 +41,7 @@ class NotifyWhatsAppTestCase(BaseTestCase):
self.assertEqual(payload["To"], "whatsapp:+1234567890")
n = Notification.objects.get()
callback_path = f"/api/v2/notifications/{n.code}/status"
callback_path = f"/api/v3/notifications/{n.code}/status"
self.assertTrue(payload["StatusCallback"].endswith(callback_path))
# sent SMS counter should go up

View file

@ -14,7 +14,7 @@ class PruneNotificationsTestCase(BaseTestCase):
super().setUp()
self.check = Check(project=self.project)
self.check.set_name_slug("Alice 1")
self.check.name = "Alice 1"
self.check.n_pings = 101
self.check.save()

View file

@ -398,3 +398,22 @@ class UpdateCheckTestCase(BaseTestCase):
doc = r.json()
self.assertEqual(doc["status"], "new")
self.assertTrue(doc["started"])
def test_v3_saves_slug(self):
payload = {"slug": "updated-slug", "api_key": "X" * 32}
r = self.post(self.check.code, payload, v=3)
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
self.assertEqual(self.check.slug, "updated-slug")
def test_v3_does_not_autogenerate_slug(self):
self.check.slug = "foo"
self.check.save()
payload = {"name": "Bar", "api_key": "X" * 32}
r = self.post(self.check.code, payload, v=3)
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
self.assertEqual(self.check.slug, "foo")

View file

@ -78,6 +78,7 @@ urlpatterns = [
path("ping/<slug:ping_key>/<slug:slug>/", include(slug_urls)),
path("api/v1/", include(api_urls)),
path("api/v2/", include(api_urls)),
path("api/v3/", include(api_urls)),
path(
"badge/<slug:badge_key>/<slug:signature>/<quoted:tag>.<slug:fmt>",
views.badge,

View file

@ -20,6 +20,7 @@ from django.http import (
JsonResponse,
)
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from django.utils.timezone import now
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
@ -115,6 +116,8 @@ def _lookup(project, spec):
existing_checks = Check.objects.filter(project=project)
if "name" in unique_fields:
existing_checks = existing_checks.filter(name=spec.get("name"))
if "slug" in unique_fields:
existing_checks = existing_checks.filter(slug=spec.get("slug"))
if "tags" in unique_fields:
existing_checks = existing_checks.filter(tags=spec.get("tags"))
if "timeout" in unique_fields:
@ -127,7 +130,7 @@ def _lookup(project, spec):
return existing_checks.first()
def _update(check, spec):
def _update(check, spec, v: int):
# First, validate the supplied channel codes/names
if "channels" not in spec:
# If the channels key is not present, don't update check's channels
@ -162,7 +165,10 @@ def _update(check, spec):
need_save = True
if "name" in spec and check.name != spec["name"]:
check.set_name_slug(spec["name"])
check.name = spec["name"]
if v < 3:
# v1 and v2 generates slug automatically from name
check.slug = slugify(spec["name"])
need_save = True
if "timeout" in spec and "schedule" not in spec:
@ -195,6 +201,7 @@ def _update(check, spec):
need_save = True
for key in (
"slug",
"tags",
"desc",
"manual_resume",
@ -255,7 +262,7 @@ def create_check(request):
created = True
try:
_update(check, request.json)
_update(check, request.json, request.v)
except BadChannelException as e:
return JsonResponse({"error": e.message}, status=400)
@ -308,7 +315,7 @@ def update_check(request, code):
return HttpResponseForbidden()
try:
_update(check, request.json)
_update(check, request.json, request.v)
except BadChannelException as e:
return JsonResponse({"error": e.message}, status=400)

View file

@ -9,7 +9,7 @@ class CopyCheckTestCase(BaseTestCase):
super().setUp()
self.check = Check(project=self.project)
self.check.name = "Foo"
self.check.slug = "foo"
self.check.slug = "custom-slug"
self.check.filter_subject = True
self.check.filter_body = True
self.check.start_kw = "start-keyword"
@ -27,7 +27,7 @@ class CopyCheckTestCase(BaseTestCase):
self.assertContains(r, "This is a brand new check")
copy = Check.objects.get(name="Foo (copy)")
self.assertEqual(copy.slug, "foo-copy")
self.assertEqual(copy.slug, "custom-slug-copy")
self.assertTrue(copy.filter_subject)
self.assertTrue(copy.filter_body)
self.assertEqual(copy.start_kw, "start-keyword")

View file

@ -936,8 +936,13 @@ def copy(request, code):
if len(new_name) > 100:
new_name = check.name[:90] + "... (copy)"
new_slug = check.slug + "-copy"
if len(new_slug) > 100:
new_slug = ""
copied = Check(project=check.project)
copied.set_name_slug(new_name)
copied.name = new_name
copied.slug = new_slug
copied.desc, copied.tags = check.desc, check.tags
copied.filter_subject = check.filter_subject