diff --git a/lago_python_client/mixins.py b/lago_python_client/mixins.py index 5c4d7df6..33ea7586 100644 --- a/lago_python_client/mixins.py +++ b/lago_python_client/mixins.py @@ -395,6 +395,7 @@ class NestedCreateCommandMixin(Generic[_M]): def create( self: _ClientMixin[_M], *args: Union[str, BaseModel], + options: QueryPairs = None, timeout: Optional[httpx.Timeout] = None, ) -> Optional[_M]: """Execute `create` command.""" @@ -402,7 +403,7 @@ def create( # Send request and save response api_response: Response = send_post_request( - url=make_url(origin=self.base_url, path_parts=self.api_resource(*parent_ids)), + url=make_url(origin=self.base_url, path_parts=self.api_resource(*parent_ids), query_pairs=options), content=to_json( { self.ROOT_NAME: input_object.dict(exclude_none=True), @@ -430,6 +431,7 @@ class NestedUpdateCommandMixin(Generic[_M]): def update( self: _ClientMixin[_M], *args: Union[str, BaseModel], + options: QueryPairs = None, timeout: Optional[httpx.Timeout] = None, ) -> _M: """Execute `update` command.""" @@ -440,6 +442,7 @@ def update( url=make_url( origin=self.base_url, path_parts=self.api_resource(*parent_ids) + (resource_id,), + query_pairs=options, ), content=to_json( { @@ -464,6 +467,7 @@ class NestedDestroyCommandMixin(Generic[_M]): def destroy( self: _ClientMixin[_M], *args: str, + options: QueryPairs = None, timeout: Optional[httpx.Timeout] = None, ) -> BaseModel: """Execute `destroy` command.""" @@ -474,6 +478,7 @@ def destroy( url=make_url( origin=self.base_url, path_parts=self.api_resource(*parent_ids) + (resource_id,), + query_pairs=options, ), headers=make_headers(api_key=self.api_key), timeout=timeout, @@ -493,6 +498,7 @@ class NestedFindCommandMixin(Generic[_M]): def find( self: _ClientMixin[_M], *args: str, + options: QueryPairs = None, timeout: Optional[httpx.Timeout] = None, ) -> _M: """Execute `find` command.""" @@ -503,6 +509,7 @@ def find( url=make_url( origin=self.base_url, path_parts=self.api_resource(*parent_ids) + (resource_id,), + query_pairs=options, ), headers=make_headers(api_key=self.api_key), timeout=timeout, diff --git a/lago_python_client/subscriptions/alert_client.py b/lago_python_client/subscriptions/alert_client.py new file mode 100644 index 00000000..6a1934b8 --- /dev/null +++ b/lago_python_client/subscriptions/alert_client.py @@ -0,0 +1,86 @@ +from typing import Any, ClassVar, Mapping, Optional, Type + +import httpx + +from ..base_client import BaseClient +from ..mixins import ( + NestedCreateCommandMixin, + NestedDestroyCommandMixin, + NestedFindAllCommandMixin, + NestedFindCommandMixin, + NestedUpdateCommandMixin, +) +from ..models.alert import Alert, AlertResponse +from ..services.request import QueryPairs + + +class SubscriptionAlertClient( + NestedCreateCommandMixin[AlertResponse], + NestedUpdateCommandMixin[AlertResponse], + NestedDestroyCommandMixin[AlertResponse], + NestedFindCommandMixin[AlertResponse], + NestedFindAllCommandMixin[AlertResponse], + BaseClient, +): + API_RESOURCE: ClassVar[str] = "alerts" + RESPONSE_MODEL: ClassVar[Type[AlertResponse]] = AlertResponse + ROOT_NAME: ClassVar[str] = "alert" + + def api_resource(self, subscription_id: str) -> tuple[str]: + return ("subscriptions", subscription_id, "alerts") + + # The API only looks up active subscriptions unless `subscription_status` is given. + def create( + self, + subscription_id: str, + input_object: Alert, + subscription_status: Optional[str] = None, + timeout: Optional[httpx.Timeout] = None, + ) -> Optional[AlertResponse]: + return super().create( + subscription_id, input_object, options=_status_query(subscription_status), timeout=timeout + ) + + def update( + self, + subscription_id: str, + code: str, + input_object: Alert, + subscription_status: Optional[str] = None, + timeout: Optional[httpx.Timeout] = None, + ) -> AlertResponse: + return super().update( + subscription_id, code, input_object, options=_status_query(subscription_status), timeout=timeout + ) + + def destroy( + self, + subscription_id: str, + code: str, + subscription_status: Optional[str] = None, + timeout: Optional[httpx.Timeout] = None, + ) -> AlertResponse: + return super().destroy(subscription_id, code, options=_status_query(subscription_status), timeout=timeout) + + def find( + self, + subscription_id: str, + code: str, + subscription_status: Optional[str] = None, + timeout: Optional[httpx.Timeout] = None, + ) -> AlertResponse: + return super().find(subscription_id, code, options=_status_query(subscription_status), timeout=timeout) + + def find_all( + self, + subscription_id: str, + options: QueryPairs = None, + subscription_status: Optional[str] = None, + timeout: Optional[httpx.Timeout] = None, + ) -> Mapping[str, Any]: + query = {**dict(options or {}), **_status_query(subscription_status)} + return super().find_all(subscription_id, options=query, timeout=timeout) + + +def _status_query(subscription_status: Optional[str]) -> dict: + return {} if subscription_status is None else {"subscription_status": subscription_status} diff --git a/lago_python_client/subscriptions/clients.py b/lago_python_client/subscriptions/clients.py index 505a945a..810dfeaa 100644 --- a/lago_python_client/subscriptions/clients.py +++ b/lago_python_client/subscriptions/clients.py @@ -2,6 +2,7 @@ from ..base_client import BaseClient from ..base_model import BaseModel +from ..functools_ext import callable_cached_property from ..mixins import ( CreateCommandMixin, DestroyCommandMixin, @@ -29,6 +30,7 @@ prepare_object_response, verify_response, ) +from .alert_client import SubscriptionAlertClient class SubscriptionClient( @@ -43,6 +45,12 @@ class SubscriptionClient( RESPONSE_MODEL: ClassVar[Type[SubscriptionResponse]] = SubscriptionResponse ROOT_NAME: ClassVar[str] = "subscription" + @callable_cached_property + def alerts(self) -> SubscriptionAlertClient: + return SubscriptionAlertClient( + self.base_url, self.api_key, rate_limit_retry_config=self.rate_limit_retry_config + ) + def lifetime_usage(self, resource_id: str) -> LifetimeUsageResponse: api_response: Response = send_get_request( url=make_url( diff --git a/tests/fixtures/subscription_alert.json b/tests/fixtures/subscription_alert.json new file mode 100644 index 00000000..eb32900b --- /dev/null +++ b/tests/fixtures/subscription_alert.json @@ -0,0 +1,46 @@ +{ + "alert": { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_organization_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "external_subscription_id": "subscription_id", + "lago_wallet_id": null, + "wallet_code": null, + "alert_type": "billable_metric_current_usage_amount", + "code": "storage_threshold_alert", + "name": "Storage Usage Alert", + "billable_metric": { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "name": "Storage", + "code": "storage", + "description": "GB of storage used in my application", + "recurring": false, + "rounding_function": "round", + "rounding_precision": 2, + "created_at": "2022-09-14T16:35:31Z", + "expression": "round((ended_at - started_at) * units)", + "field_name": "gb", + "aggregation_type": "sum_agg", + "weighted_interval": "seconds", + "filters": [ + { + "key": "region", + "values": [ + "us-east-1" + ] + } + ] + }, + "previous_value": 1000, + "direction": "increasing", + "thresholds": [ + { + "code": "warn", + "recurring": false, + "value": "99.0", + "notify_on": ["triggered", "resolved"] + } + ], + "created_at": "2025-03-20T10:00:00Z", + "last_processed_at": "2025-05-19T10:04:21Z" + } +} diff --git a/tests/fixtures/subscription_alert_index.json b/tests/fixtures/subscription_alert_index.json new file mode 100644 index 00000000..afe2c419 --- /dev/null +++ b/tests/fixtures/subscription_alert_index.json @@ -0,0 +1,55 @@ +{ + "alerts": [ + { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "lago_organization_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "external_subscription_id": "subscription_id", + "lago_wallet_id": null, + "wallet_code": null, + "alert_type": "billable_metric_current_usage_amount", + "code": "storage_threshold_alert", + "name": "Storage Usage Alert", + "billable_metric": { + "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a90", + "name": "Storage", + "code": "storage", + "description": "GB of storage used in my application", + "recurring": false, + "rounding_function": "round", + "rounding_precision": 2, + "created_at": "2022-09-14T16:35:31Z", + "expression": "round((ended_at - started_at) * units)", + "field_name": "gb", + "aggregation_type": "sum_agg", + "weighted_interval": "seconds", + "filters": [ + { + "key": "region", + "values": [ + "us-east-1" + ] + } + ] + }, + "previous_value": 1000, + "direction": "increasing", + "thresholds": [ + { + "code": "warn", + "recurring": false, + "value": "99.0", + "notify_on": ["triggered", "resolved"] + } + ], + "created_at": "2025-03-20T10:00:00Z", + "last_processed_at": "2025-05-19T10:04:21Z" + } + ], + "meta": { + "current_page": 1, + "next_page": 2, + "prev_page": null, + "total_pages": 4, + "total_count": 70 + } +} diff --git a/tests/test_subscription_alert_client.py b/tests/test_subscription_alert_client.py new file mode 100644 index 00000000..e19b4fc5 --- /dev/null +++ b/tests/test_subscription_alert_client.py @@ -0,0 +1,265 @@ +import pytest +from pytest_httpx import HTTPXMock + +from lago_python_client.client import Client +from lago_python_client.exceptions import LagoApiError +from lago_python_client.models import ( + Alert, + AlertThreshold, + AlertThresholdList, +) + +from .utils.mixin import mock_response + + +def alert_object(): + threshold = AlertThreshold(code="warn", value=10000, recurring=False, notify_on=["triggered", "resolved"]) + + return Alert( + alert_type="billable_metric_current_usage_amount", + code="storage_threshold_alert", + name="Storage Usage Alert", + billable_metric_code="storage", + thresholds=AlertThresholdList(__root__=[threshold]), + ) + + +ALERT_PAYLOAD = { + "alert": { + "alert_type": "billable_metric_current_usage_amount", + "code": "storage_threshold_alert", + "name": "Storage Usage Alert", + "billable_metric_code": "storage", + "thresholds": [{"code": "warn", "value": "10000", "recurring": False, "notify_on": ["triggered", "resolved"]}], + } +} + + +def test_valid_create_subscription_alert_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts", + match_json=ALERT_PAYLOAD, + content=mock_response("subscription_alert"), + ) + response = client.subscriptions.alerts.create("subscription_id", alert_object()) + + assert response.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert response.lago_organization_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert response.external_subscription_id == "subscription_id" + assert response.lago_wallet_id is None + assert response.wallet_code is None + assert response.code == "storage_threshold_alert" + assert response.name == "Storage Usage Alert" + assert response.alert_type == "billable_metric_current_usage_amount" + assert response.previous_value == "1000" + assert response.billable_metric.code == "storage" + assert response.thresholds == AlertThresholdList( + __root__=[AlertThreshold(code="warn", value=99.0, recurring=False, notify_on=["triggered", "resolved"])] + ) + + +def test_invalid_create_subscription_alert_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + + httpx_mock.add_response( + method="POST", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts", + status_code=401, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.subscriptions.alerts.create("subscription_id", alert_object()) + + +def test_valid_update_subscription_alert_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + code = "alert-code" + + httpx_mock.add_response( + method="PUT", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts/" + code, + match_json=ALERT_PAYLOAD, + content=mock_response("subscription_alert"), + ) + response = client.subscriptions.alerts.update("subscription_id", code, alert_object()) + + assert response.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + + +def test_invalid_update_subscription_alert_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + code = "invalid" + + httpx_mock.add_response( + method="PUT", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts/" + code, + status_code=401, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.subscriptions.alerts.update("subscription_id", code, alert_object()) + + +def test_valid_find_subscription_alert_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + code = "alert-code" + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts/" + code, + content=mock_response("subscription_alert"), + ) + response = client.subscriptions.alerts.find("subscription_id", code) + + assert response.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + + +def test_invalid_find_subscription_alert_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + code = "invalid" + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts/" + code, + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.subscriptions.alerts.find("subscription_id", code) + + +def test_valid_destroy_subscription_alert_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + code = "alert-code" + + httpx_mock.add_response( + method="DELETE", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts/" + code, + content=mock_response("subscription_alert"), + ) + response = client.subscriptions.alerts.destroy("subscription_id", code) + + assert response.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + + +def test_invalid_destroy_subscription_alert_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + code = "invalid" + + httpx_mock.add_response( + method="DELETE", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts/" + code, + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.subscriptions.alerts.destroy("subscription_id", code) + + +def test_valid_find_all_subscription_alerts_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts", + content=mock_response("subscription_alert_index"), + ) + response = client.subscriptions.alerts.find_all("subscription_id") + + assert response["alerts"][0].lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert response["meta"]["current_page"] == 1 + + +def test_valid_find_all_subscription_alerts_request_with_options(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts?page=1&per_page=2", + content=mock_response("subscription_alert_index"), + ) + response = client.subscriptions.alerts.find_all("subscription_id", options={"per_page": 2, "page": 1}) + + assert response["alerts"][0].lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + assert response["meta"]["current_page"] == 1 + + +def test_invalid_find_all_subscription_alerts_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts", + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.subscriptions.alerts.find_all("subscription_id") + + +@pytest.mark.parametrize( + "method,url_suffix,call", + [ + ("POST", "", lambda alerts: alerts.create("subscription_id", alert_object(), subscription_status="pending")), + ( + "PUT", + "/alert-code", + lambda alerts: alerts.update( + "subscription_id", "alert-code", alert_object(), subscription_status="pending" + ), + ), + ( + "GET", + "/alert-code", + lambda alerts: alerts.find("subscription_id", "alert-code", subscription_status="pending"), + ), + ( + "DELETE", + "/alert-code", + lambda alerts: alerts.destroy("subscription_id", "alert-code", subscription_status="pending"), + ), + ], +) +def test_subscription_alert_request_with_subscription_status(httpx_mock: HTTPXMock, method, url_suffix, call): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method=method, + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts" + + url_suffix + + "?subscription_status=pending", + content=mock_response("subscription_alert"), + ) + response = call(client.subscriptions.alerts) + + assert response.lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + + +def test_find_all_subscription_alerts_request_with_subscription_status(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url="https://api.getlago.com/api/v1/subscriptions/subscription_id/alerts?page=1&subscription_status=terminated", + content=mock_response("subscription_alert_index"), + ) + response = client.subscriptions.alerts.find_all( + "subscription_id", options={"page": 1}, subscription_status="terminated" + ) + + assert response["alerts"][0].lago_id == "1a901a90-1a90-1a90-1a90-1a901a901a90" + + +def test_subscription_alerts_client_uses_client_rate_limit_config(): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d", max_retries=0) + + assert client.subscriptions.alerts.rate_limit_retry_config is client.subscriptions.rate_limit_retry_config + assert client.subscriptions.alerts.rate_limit_retry_config.max_retries == 0