From 7c3f263b5a07cb6657ec3a883b54ee9863822d50 Mon Sep 17 00:00:00 2001 From: Andrew Herrington Date: Fri, 10 Dec 2021 22:42:03 -0600 Subject: [PATCH] more api methods --- src/healthchecks_io/client/_abstract.py | 9 +- src/healthchecks_io/client/asyncclient.py | 116 +++++++++++++++++++++- src/healthchecks_io/client/exceptions.py | 5 + src/healthchecks_io/schemas/checks.py | 2 +- tests/client/test_async.py | 100 ++++++++++++++++++- tests/conftest.py | 60 +++++++++++ 6 files changed, 284 insertions(+), 8 deletions(-) diff --git a/src/healthchecks_io/client/_abstract.py b/src/healthchecks_io/client/_abstract.py index 334ca19..9cd8612 100644 --- a/src/healthchecks_io/client/_abstract.py +++ b/src/healthchecks_io/client/_abstract.py @@ -15,7 +15,7 @@ from weakref import finalize from httpx import Client, Response from healthchecks_io.schemas import checks -from .exceptions import HCAPIAuthError, HCAPIError, CheckNotFoundError +from .exceptions import HCAPIAuthError, HCAPIError, CheckNotFoundError, BadAPIRequestError class AbstractClient(ABC): @@ -90,6 +90,7 @@ class AbstractClient(ABC): Raises: HCAPIAuthError: Raised when status_code == 401 or 403 HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 Returns: Response: the passed in response object @@ -103,6 +104,12 @@ class AbstractClient(ABC): f"Status Code {response.status_code}. Response {response.text}" ) + if response.status_code == 404: + raise CheckNotFoundError(f"CHeck not found at {response.request.url}") + + if response.status_code == 400: + raise BadAPIRequestError(f"Bad request when requesting {response.request.url}. {response.text}") + return response @staticmethod diff --git a/src/healthchecks_io/client/asyncclient.py b/src/healthchecks_io/client/asyncclient.py index 88f640d..54ca797 100644 --- a/src/healthchecks_io/client/asyncclient.py +++ b/src/healthchecks_io/client/asyncclient.py @@ -6,8 +6,6 @@ from typing import Optional from httpx import AsyncClient as HTTPXAsyncClient from ._abstract import AbstractClient -from .exceptions import HCAPIAuthError, CheckNotFoundError -from .exceptions import HCAPIError from healthchecks_io import VERSION from healthchecks_io.schemas import checks @@ -89,11 +87,119 @@ class AsyncClient(AbstractClient): checks.Check: the check Raises: - CheckNotFoundError: when no check with check_id is found + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 + """ request_url = self._get_api_request_url(f"checks/{check_id}") response = self.check_response(await self._client.get(request_url)) - if response.status_code == 404: - raise CheckNotFoundError(f"{check_id} not found at {request_url}") return checks.Check.from_api_result(response.json()) + async def pause_check(self, check_id: str) -> checks.Check: + """Disables monitoring for a check without removing it. + + The check goes into a "paused" state. + You can resume monitoring of the check by pinging it. + + check_id must be a uuid, not a unique id + + Args: + check_id (str): check's uuid + + Returns: + checks.Check: the check just paused + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 + + """ + request_url = self._get_api_request_url(f"checks/{check_id}/pause") + response = self.check_response(await self._client.post(request_url , data={})) + return checks.Check.from_api_result(response.json()) + + async def delete_check(self, check_id: str) -> checks.Check: + """Permanently deletes the check from the user's account. + + check_id must be a uuid, not a unique id + + Args: + check_id (str): check's uuid + + Returns: + checks.Check: the check just deleted + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 + + """ + request_url = self._get_api_request_url(f"checks/{check_id}") + response = self.check_response(await self._client.delete(request_url)) + return checks.Check.from_api_result(response.json()) + + async def get_check_pings(self, check_id: str) -> List[checks.CheckPings]: + """Returns a list of pings this check has received. + + This endpoint returns pings in reverse order (most recent first), + and the total number of returned pings depends on the account's + billing plan: 100 for free accounts, 1000 for paid accounts. + + Args: + check_id (str): check's uuid + + Returns: + List[checks.CheckPings]: list of pings this check has received + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 + + """ + request_url = self._get_api_request_url(f"checks/{check_id}/pings/") + response = self.check_response(await self._client.get(request_url)) + return [ + checks.CheckPings.from_api_result(check_data) + for check_data in response.json()["pings"] + ] + + async def get_check_flips(self, check_id: str, seconds: Optional[int] = None, start: Optional[int] = None, end: Optional[int] = None) -> List[checks.CheckStatuses]: + """ + 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"). + + Raises: + HCAPIAuthError: Raised when status_code == 401 or 403 + HCAPIError: Raised when status_code is 5xx + CheckNotFoundError: Raised when status_code is 404 + BadAPIRequestError: Raised when status_code is 400 + + Args: + check_id (str): check uuid + seconds (Optional[int], optional): Returns the flips from the last value seconds. Defaults to None. + start (Optional[int], optional): Returns flips that are newer than the specified UNIX timestamp.. Defaults to None. + end (Optional[int], optional): Returns flips that are older than the specified UNIX timestamp.. Defaults to None. + + Returns: + List[checks.CheckStatuses]: List of status flips for this check + + """ + params = dict() + if seconds is not None and seconds >=0: + params['seconds'] = seconds + if start is not None and start >= 0: + params['start'] = start + if end is not None and end >= 0: + params['end'] = end + + request_url = self._get_api_request_url(f"checks/{check_id}/flips/", params) + response = self.check_response(await self._client.get(request_url)) + return [ + checks.CheckStatuses(**status_data) + for status_data in response.json() + ] diff --git a/src/healthchecks_io/client/exceptions.py b/src/healthchecks_io/client/exceptions.py index 751b5b2..1737310 100644 --- a/src/healthchecks_io/client/exceptions.py +++ b/src/healthchecks_io/client/exceptions.py @@ -15,4 +15,9 @@ class HCAPIAuthError(HCAPIError): class CheckNotFoundError(HCAPIError): """Thrown when getting a check returns a 404.""" + ... + +class BadAPIRequestError(HCAPIError): + """Thrown when an api request returns a 400.""" + ... \ No newline at end of file diff --git a/src/healthchecks_io/schemas/checks.py b/src/healthchecks_io/schemas/checks.py index 760c5cd..b6ab386 100644 --- a/src/healthchecks_io/schemas/checks.py +++ b/src/healthchecks_io/schemas/checks.py @@ -173,7 +173,7 @@ class CheckPings(BaseModel): remote_addr: str method: str user_agent: str - duration: float + duration: Optional[float] = None @classmethod def from_api_result( diff --git a/tests/client/test_async.py b/tests/client/test_async.py index 64820f1..0317d72 100644 --- a/tests/client/test_async.py +++ b/tests/client/test_async.py @@ -7,7 +7,7 @@ from httpx import Response from healthchecks_io.client import AsyncClient from healthchecks_io.client.exceptions import HCAPIAuthError -from healthchecks_io.client.exceptions import HCAPIError, CheckNotFoundError +from healthchecks_io.client.exceptions import HCAPIError, CheckNotFoundError, BadAPIRequestError @pytest.mark.asyncio @@ -98,3 +98,101 @@ async def test_check_get_404(respx_mock, test_async_client): ) with pytest.raises(CheckNotFoundError): await test_async_client.get_check("test") + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_pause_check_200(fake_check_api_result, respx_mock, test_async_client): + assert test_async_client._client is not None + checks_url = urljoin(test_async_client._api_url, "checks/test/pause") + respx_mock.post(checks_url).mock( + return_value=Response(status_code=200, json=fake_check_api_result) + ) + check = await test_async_client.pause_check(check_id="test") + assert check.name == fake_check_api_result["name"] + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_check_pause_404(respx_mock, test_async_client): + assert test_async_client._client is not None + checks_url = urljoin(test_async_client._api_url, "checks/test/pause") + respx_mock.post(checks_url).mock( + return_value=Response(status_code=404) + ) + with pytest.raises(CheckNotFoundError): + await test_async_client.pause_check("test") + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_delete_check_200(fake_check_api_result, respx_mock, test_async_client): + assert test_async_client._client is not None + checks_url = urljoin(test_async_client._api_url, "checks/test") + respx_mock.delete(checks_url).mock( + return_value=Response(status_code=200, json=fake_check_api_result) + ) + check = await test_async_client.delete_check(check_id="test") + assert check.name == fake_check_api_result["name"] + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_delete_pause404(respx_mock, test_async_client): + assert test_async_client._client is not None + checks_url = urljoin(test_async_client._api_url, "checks/test") + respx_mock.delete(checks_url).mock( + return_value=Response(status_code=404) + ) + with pytest.raises(CheckNotFoundError): + await test_async_client.delete_check("test") + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_get_check_pings_200(fake_check_pings_api_result, respx_mock, test_async_client): + assert test_async_client._client is not None + checks_url = urljoin(test_async_client._api_url, "checks/test/pings/") + respx_mock.get(checks_url).mock( + return_value=Response(status_code=200, json={"pings": fake_check_pings_api_result}) + ) + pings = await test_async_client.get_check_pings("test") + assert len(pings) == len(fake_check_pings_api_result) + assert pings[0].type == fake_check_pings_api_result[0]['type'] + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_get_check_flips_200(fake_check_flips_api_result, respx_mock, test_async_client): + assert test_async_client._client is not None + checks_url = urljoin(test_async_client._api_url, "checks/test/flips/") + respx_mock.get(checks_url).mock( + return_value=Response(status_code=200, json=fake_check_flips_api_result) + ) + flips = await test_async_client.get_check_flips("test") + assert len(flips) == len(fake_check_flips_api_result) + assert flips[0].up == fake_check_flips_api_result[0]['up'] + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_get_check_flips_params_200(fake_check_flips_api_result, respx_mock, test_async_client): + assert test_async_client._client is not None + checks_url = urljoin(test_async_client._api_url, "checks/test/flips/?seconds=1&start=1&end=1") + respx_mock.get(checks_url).mock( + return_value=Response(status_code=200, json=fake_check_flips_api_result) + ) + flips = await test_async_client.get_check_flips("test", seconds=1, start=1, end=1) + assert len(flips) == len(fake_check_flips_api_result) + assert flips[0].up == fake_check_flips_api_result[0]['up'] + + +@pytest.mark.asyncio +@pytest.mark.respx +async def test_get_check_flips_400(fake_check_flips_api_result, respx_mock, test_async_client): + assert test_async_client._client is not None + checks_url = urljoin(test_async_client._api_url, "checks/test/flips/") + respx_mock.get(checks_url).mock( + return_value=Response(status_code=400) + ) + with pytest.raises(BadAPIRequestError): + await test_async_client.get_check_flips("test") \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 553bfe2..3ddd00c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,3 +86,63 @@ def test_async_client(): """An AsyncClient for testing, set to a nonsense url so we aren't pinging healtchecks.""" yield AsyncClient(api_key="test", api_url="https://localhost/api") + +@pytest.fixture +def fake_check_pings_api_result(): + return [ + { + "type": "success", + "date": "2020-06-09T14:51:06.113073+00:00", + "n": 4, + "scheme": "http", + "remote_addr": "192.0.2.0", + "method": "GET", + "ua": "curl/7.68.0", + "duration": 2.896736 + }, + { + "type": "start", + "date": "2020-06-09T14:51:03.216337+00:00", + "n": 3, + "scheme": "http", + "remote_addr": "192.0.2.0", + "method": "GET", + "ua": "curl/7.68.0" + }, + { + "type": "success", + "date": "2020-06-09T14:50:59.633577+00:00", + "n": 2, + "scheme": "http", + "remote_addr": "192.0.2.0", + "method": "GET", + "ua": "curl/7.68.0", + "duration": 2.997976 + }, + { + "type": "start", + "date": "2020-06-09T14:50:56.635601+00:00", + "n": 1, + "scheme": "http", + "remote_addr": "192.0.2.0", + "method": "GET", + "ua": "curl/7.68.0" + } + ] + +@pytest.fixture +def fake_check_flips_api_result(): + return [ + { + "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 + } + ] \ No newline at end of file