diff --git a/pyproject.toml b/pyproject.toml index 8317e0a..dcc4ade 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ ] dependencies = [ "django>=4.2", - "typing-extensions; python_version<'3.11'" + "typing-extensions; python_version<'3.12'" ] [project.urls] diff --git a/src/healthy/backends.py b/src/healthy/backends.py index f563e15..a18eab8 100644 --- a/src/healthy/backends.py +++ b/src/healthy/backends.py @@ -1,16 +1,20 @@ # SPDX-FileCopyrightText: 2024-present OLIST TINY TECNOLOGIA LTDA # # SPDX-License-Identifier: MIT -from dataclasses import dataclass, field +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import overload, Optional, Union +from dataclasses import dataclass, field +from typing import overload + +from .compat import Self, StrEnum, override -from .compat import Self, override, StrEnum class HealthStatus(StrEnum): UP = "up" DOWN = "down" + @dataclass class Health: status: HealthStatus @@ -27,9 +31,9 @@ def up(cls, details: dict) -> Self: ... @classmethod - def up(cls, details: Optional[dict] = None) -> Self: + def up(cls, details: dict | None = None) -> Self: if details is None: - details = dict() + details = {} return cls(status=HealthStatus.UP, details=details) @@ -49,9 +53,9 @@ def down(cls, details: dict) -> Self: ... @classmethod - def down(cls, details: Optional[Union[dict, Exception]] = None) -> Self: + def down(cls, details: dict | Exception | None = None) -> Self: if details is None: - details = dict() + details = {} elif isinstance(details, Exception): details = {"error": str(details)} @@ -62,7 +66,7 @@ class HealthBackend(ABC): async def run(self) -> Health: try: health = await self.run_health_check() - except Exception as exc: + except Exception as exc: # noqa: BLE001 health = Health.down(exc) return health diff --git a/src/healthy/compat.py b/src/healthy/compat.py index 1b7c35a..5dfe103 100644 --- a/src/healthy/compat.py +++ b/src/healthy/compat.py @@ -1,4 +1,3 @@ - try: from typing import Self except ImportError: @@ -17,6 +16,7 @@ class StrEnum(str, Enum): pass + __all__ = [ "Self", "override", diff --git a/src/healthy/responses.py b/src/healthy/responses.py new file mode 100644 index 0000000..559cc32 --- /dev/null +++ b/src/healthy/responses.py @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2024-present OLIST TINY TECNOLOGIA LTDA +# +# SPDX-License-Identifier: MIT +from dataclasses import asdict +from http import HTTPStatus + +from django.http import JsonResponse + +from .backends import Health, HealthStatus + + +class HealthResponse(JsonResponse): + def __init__(self, health: Health): + status = HTTPStatus.OK if health.status == HealthStatus.UP else HTTPStatus.INTERNAL_SERVER_ERROR + super().__init__(data=asdict(health), status=status) diff --git a/src/healthy/urls.py b/src/healthy/urls.py index e165bd9..5430907 100644 --- a/src/healthy/urls.py +++ b/src/healthy/urls.py @@ -3,10 +3,10 @@ # SPDX-License-Identifier: MIT from django.urls import path -from .views import PingView +from .views import LivenessView app_name = "healthy" urlpatterns = [ - path("ping/", PingView.as_view(), name="ping"), + path("ping/", LivenessView.as_view(), name="ping"), ] diff --git a/src/healthy/views.py b/src/healthy/views.py index 83e5e44..ec40c9d 100644 --- a/src/healthy/views.py +++ b/src/healthy/views.py @@ -1,14 +1,16 @@ # SPDX-FileCopyrightText: 2024-present OLIST TINY TECNOLOGIA LTDA # # SPDX-License-Identifier: MIT -from http import HTTPStatus from typing import ClassVar from django.http import HttpRequest, HttpResponse from django.views import View +from .backends import LivenessHealthBackend +from .responses import HealthResponse -class PingView(View): + +class LivenessView(View): http_method_names: ClassVar = [ "get", "head", @@ -17,4 +19,6 @@ class PingView(View): ] async def get(self, request: HttpRequest) -> HttpResponse: # noqa: ARG002 - return HttpResponse("Pong", status=HTTPStatus.OK) + backend = LivenessHealthBackend() + health = await backend.run() + return HealthResponse(health) diff --git a/tests/test_backends.py b/tests/test_backends.py index f736948..f30d2e0 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -5,6 +5,7 @@ from healthy import backends from healthy.compat import override + class TestHealth: def test_up_without_args(self): got = backends.Health.up() @@ -39,13 +40,15 @@ def test_down_with_exception_details(self): assert got.status == backends.HealthStatus.DOWN assert got.details == {"error": given_message} + @pytest.mark.asyncio class TestHealthBackend: async def test_run_handles_exceptions(self): class FaultHealthBackend(backends.HealthBackend): @override async def run_health_check(self) -> backends.Health: - raise RuntimeError("Something went wrong") + msg = "Something went wrong" + raise RuntimeError(msg) backend = FaultHealthBackend() got = await backend.run() @@ -57,6 +60,7 @@ async def run_health_check(self) -> backends.Health: async def test_run_with_successful_check(self): expected = backends.Health.up({"message": "It's fine"}) + class ProxyHealthBackend(backends.HealthBackend): def __init__(self, health: backends.Health): self.health = health @@ -72,7 +76,7 @@ async def run_health_check(self) -> backends.Health: assert got == expected -@pytest.mark.asyncio() +@pytest.mark.asyncio class TestLivenessHealthBackend: async def test_run_health_check(self): backend = backends.LivenessHealthBackend() diff --git a/tests/test_responses.py b/tests/test_responses.py new file mode 100644 index 0000000..83d5756 --- /dev/null +++ b/tests/test_responses.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2024-present OLIST TINY TECNOLOGIA LTDA +# +# SPDX-License-Identifier: MIT +import json +from http import HTTPStatus + +from healthy.backends import Health +from healthy.responses import HealthResponse + + +class TestHealthResponse: + def test_with_up_health(self): + health = Health.up({"message": "It's fine"}) + response = HealthResponse(health) + + assert response.status_code == HTTPStatus.OK + assert json.loads(response.content) == {"status": "up", "details": {"message": "It's fine"}} + + def test_with_down_health(self): + health = Health.down({"message": "Something went wrong"}) + response = HealthResponse(health) + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert json.loads(response.content) == {"status": "down", "details": {"message": "Something went wrong"}}