From 2637ae55699156fcc4f3c005aea2071de56458b4 Mon Sep 17 00:00:00 2001 From: caballeto Date: Wed, 10 Jun 2026 15:03:48 +0200 Subject: [PATCH] feat: add services catalog resource and complete dependencies coverage New client.services resource covering the full public catalog surface: list (with search), get, status, summary, categories, components, component_uptime, batch_component_uptime, day, uptime, incidents (per-service and cross-service), incident detail, and maintenances. Completes client.dependencies with get/update. Vendors the updated OpenAPI spec (services search param) and extends spec-parity tests. Co-authored-by: Cursor --- README.md | 1 + docs/openapi/monitoring-api.json | 9 + src/devhelm/__init__.py | 34 +++ src/devhelm/_pagination.py | 13 +- src/devhelm/client.py | 3 + src/devhelm/resources/dependencies.py | 61 ++++- src/devhelm/resources/services.py | 322 ++++++++++++++++++++++++++ src/devhelm/types.py | 32 +++ tests/test_client.py | 19 ++ tests/test_dependencies.py | 142 ++++++++++++ tests/test_services.py | 281 ++++++++++++++++++++++ tests/test_spec_parity.py | 4 + uv.lock | 2 +- 13 files changed, 914 insertions(+), 9 deletions(-) create mode 100644 src/devhelm/resources/services.py create mode 100644 tests/test_dependencies.py create mode 100644 tests/test_services.py diff --git a/README.md b/README.md index 47742da..5db107b 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ The client exposes the following resource modules: | `client.api_keys` | API key management | | `client.dependencies` | Service dependency tracking | | `client.deploy_lock` | Deploy lock for safe deployments | +| `client.services` | Status Data catalog: vendor services, components, incidents, uptime | | `client.status` | Dashboard overview | ## Pagination diff --git a/docs/openapi/monitoring-api.json b/docs/openapi/monitoring-api.json index 22b4050..444dc1b 100644 --- a/docs/openapi/monitoring-api.json +++ b/docs/openapi/monitoring-api.json @@ -13520,6 +13520,15 @@ "type": "boolean" } }, + { + "name": "search", + "in": "query", + "description": "Case-insensitive substring match on service name or slug", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "cursor", "in": "query", diff --git a/src/devhelm/__init__.py b/src/devhelm/__init__.py index 515c2eb..ee69de3 100644 --- a/src/devhelm/__init__.py +++ b/src/devhelm/__init__.py @@ -29,6 +29,7 @@ from devhelm.resources.notification_policies import NotificationPolicies from devhelm.resources.resource_groups import ResourceGroups from devhelm.resources.secrets import Secrets +from devhelm.resources.services import Services from devhelm.resources.status import Status from devhelm.resources.status_pages import StatusPages from devhelm.resources.tags import Tags @@ -46,8 +47,11 @@ ApiKeyDto, AssertionSeverity, AssertionTestResultDto, + BatchComponentUptimeDto, + CategoryDto, CheckResultDto, CheckTraceDto, + ComponentUptimeDayDto, ConfirmationPolicyType, CreateAlertChannelRequest, CreateApiKeyRequest, @@ -69,6 +73,7 @@ DashboardOverviewDto, DeployLockDto, EnvironmentDto, + GlobalStatusSummaryDto, IncidentDetailDto, IncidentDto, IncidentNewStatus, @@ -78,6 +83,7 @@ IncidentStatus, IncidentTimelineDto, IncidentUpdateCreatedBy, + LifecycleStatus, LinkedIncidentStatus, MaintenanceWindowDto, MembershipStatus, @@ -99,8 +105,18 @@ ResourceGroupHealthStatus, ResourceGroupMemberDto, RuleEvaluationDto, + ScheduledMaintenanceDto, SecretDto, + ServiceCatalogDto, + ServiceComponentDto, + ServiceDayDetailDto, + ServiceDetailDto, + ServiceIncidentDetailDto, + ServiceIncidentDto, + ServiceLiveStatusDto, + ServiceSubscribeRequest, ServiceSubscriptionDto, + ServiceUptimeResponse, StatusPageBranding, StatusPageComponentCurrentStatus, StatusPageComponentDto, @@ -124,6 +140,7 @@ TriggerRuleSeverity, TriggerRuleType, UpdateAlertChannelRequest, + UpdateAlertSensitivityRequest, UpdateAssertionSeverity, UpdateEnvironmentRequest, UpdateMaintenanceWindowRequest, @@ -184,6 +201,7 @@ "Dependencies", "DeployLock", "MaintenanceWindows", + "Services", "Status", "StatusPages", # Response DTOs @@ -216,6 +234,19 @@ "ApiKeyDto", "ApiKeyCreateResponse", "ServiceSubscriptionDto", + "ServiceCatalogDto", + "ServiceDetailDto", + "ServiceLiveStatusDto", + "ServiceComponentDto", + "ServiceIncidentDto", + "ServiceIncidentDetailDto", + "ServiceUptimeResponse", + "ServiceDayDetailDto", + "ScheduledMaintenanceDto", + "CategoryDto", + "GlobalStatusSummaryDto", + "BatchComponentUptimeDto", + "ComponentUptimeDayDto", "MonitorVersionDto", "CheckResultDto", "DashboardOverviewDto", @@ -261,6 +292,8 @@ "UpdateWebhookEndpointRequest", "CreateApiKeyRequest", "AcquireDeployLockRequest", + "ServiceSubscribeRequest", + "UpdateAlertSensitivityRequest", # Enum aliases (descriptive names for codegen-numbered enums) "AffectedComponentStatus", "AlertDeliveryStatus", @@ -272,6 +305,7 @@ "IncidentSeverity", "IncidentStatus", "IncidentUpdateCreatedBy", + "LifecycleStatus", "LinkedIncidentStatus", "MemberStatus", "MembershipStatus", diff --git a/src/devhelm/_pagination.py b/src/devhelm/_pagination.py index 0695719..7bb0239 100644 --- a/src/devhelm/_pagination.py +++ b/src/devhelm/_pagination.py @@ -151,9 +151,18 @@ def fetch_cursor_page( model_class: type[M], cursor: str | None = None, limit: int | None = None, + *, + extra_params: dict[str, Any] | None = None, ) -> CursorPage[M]: - """Fetch a single page from a cursor-paginated endpoint with validation.""" - params: dict[str, Any] = {} + """Fetch a single page from a cursor-paginated endpoint with validation. + + ``extra_params`` is merged into the request so callers can forward + server-side filter kwargs (``category``, ``search``, …) alongside the + cursor controls. Pagination keys (``cursor``, ``limit``) always win + over user-supplied ``extra_params`` to keep the iterator's invariants + intact. + """ + params: dict[str, Any] = dict(extra_params) if extra_params else {} if cursor: params["cursor"] = cursor if limit: diff --git a/src/devhelm/client.py b/src/devhelm/client.py index b6f310e..9449a43 100644 --- a/src/devhelm/client.py +++ b/src/devhelm/client.py @@ -15,6 +15,7 @@ from devhelm.resources.notification_policies import NotificationPolicies from devhelm.resources.resource_groups import ResourceGroups from devhelm.resources.secrets import Secrets +from devhelm.resources.services import Services from devhelm.resources.status import Status from devhelm.resources.status_pages import StatusPages from devhelm.resources.tags import Tags @@ -53,6 +54,7 @@ class Devhelm: dependencies: Dependencies deploy_lock: DeployLock maintenance_windows: MaintenanceWindows + services: Services status: Status status_pages: StatusPages @@ -99,5 +101,6 @@ def __init__( self.dependencies = Dependencies(client) self.deploy_lock = DeployLock(client) self.maintenance_windows = MaintenanceWindows(client) + self.services = Services(client) self.status = Status(client) self.status_pages = StatusPages(client) diff --git a/src/devhelm/resources/dependencies.py b/src/devhelm/resources/dependencies.py index 2635e77..e9d8a6f 100644 --- a/src/devhelm/resources/dependencies.py +++ b/src/devhelm/resources/dependencies.py @@ -2,10 +2,14 @@ import httpx -from devhelm._generated import ServiceSubscriptionDto -from devhelm._http import api_delete, api_get, api_post, path_param +from devhelm._generated import ( + ServiceSubscribeRequest, + ServiceSubscriptionDto, + UpdateAlertSensitivityRequest, +) +from devhelm._http import api_delete, api_get, api_patch, api_post, path_param from devhelm._pagination import Page, fetch_all_pages, fetch_page -from devhelm._validation import parse_single +from devhelm._validation import parse_single, validate_request class Dependencies: @@ -38,14 +42,59 @@ def get(self, id: int | str) -> ServiceSubscriptionDto: f"GET /api/v1/service-subscriptions/{id}", ) - def track(self, slug: str) -> ServiceSubscriptionDto: - """Track a new service dependency by slug.""" + def track( + self, + slug: str, + *, + component_id: str | None = None, + alert_sensitivity: str | None = None, + ) -> ServiceSubscriptionDto: + """Track a new service dependency by slug. + + ``component_id`` subscribes to one component instead of the whole + service. ``alert_sensitivity`` is one of ``ALL``, + ``INCIDENTS_ONLY``, ``MAJOR_ONLY``, or ``AWARENESS`` (the API + default — silent tracking with no alert fan-out). The request body + is omitted entirely when neither kwarg is provided. + """ + body: ServiceSubscribeRequest | None = None + if component_id is not None or alert_sensitivity is not None: + fields: dict[str, str] = {} + if component_id is not None: + fields["componentId"] = component_id + if alert_sensitivity is not None: + fields["alertSensitivity"] = alert_sensitivity + body = validate_request( + ServiceSubscribeRequest, fields, "dependencies.track" + ) return parse_single( ServiceSubscriptionDto, - api_post(self._client, f"/api/v1/service-subscriptions/{path_param(slug)}"), + api_post( + self._client, f"/api/v1/service-subscriptions/{path_param(slug)}", body + ), f"POST /api/v1/service-subscriptions/{slug}", ) + def update_alert_sensitivity( + self, subscription_id: int | str, alert_sensitivity: str + ) -> ServiceSubscriptionDto: + """Update the alert sensitivity on a tracked dependency. + + ``alert_sensitivity`` is one of ``ALL``, ``INCIDENTS_ONLY``, + ``MAJOR_ONLY``, or ``AWARENESS``. + """ + body = validate_request( + UpdateAlertSensitivityRequest, + {"alertSensitivity": alert_sensitivity}, + "dependencies.update_alert_sensitivity", + ) + path = f"/api/v1/service-subscriptions/{path_param(subscription_id)}" + return parse_single( + ServiceSubscriptionDto, + api_patch(self._client, f"{path}/alert-sensitivity", body), + f"PATCH /api/v1/service-subscriptions/{subscription_id}/alert-sensitivity", + ) + def delete(self, id: int | str) -> None: """Remove a tracked dependency.""" api_delete(self._client, f"/api/v1/service-subscriptions/{path_param(id)}") diff --git a/src/devhelm/resources/services.py b/src/devhelm/resources/services.py new file mode 100644 index 0000000..95624fa --- /dev/null +++ b/src/devhelm/resources/services.py @@ -0,0 +1,322 @@ +"""Status Data catalog: third-party service status, components, incidents, +uptime, and maintenances. + +These are the read-only ``/api/v1/services`` + ``/api/v1/categories`` +endpoints backing DevHelm's vendor-status catalog (the data the dependency +tracker subscribes to). Services are addressed by slug (``"github"``) or +UUID interchangeably — every ``slug_or_id`` parameter accepts either. +""" + +from __future__ import annotations + +import builtins +from datetime import date, datetime +from typing import TypeVar + +import httpx +from pydantic import BaseModel + +from devhelm._generated import ( + BatchComponentUptimeDto, + CategoryDto, + ComponentUptimeDayDto, + GlobalStatusSummaryDto, + ScheduledMaintenanceDto, + ServiceCatalogDto, + ServiceComponentDto, + ServiceDayDetailDto, + ServiceDetailDto, + ServiceIncidentDetailDto, + ServiceIncidentDto, + ServiceLiveStatusDto, + ServiceUptimeResponse, +) +from devhelm._http import api_get, path_param +from devhelm._pagination import CursorPage, Page, _validate_page, fetch_cursor_page +from devhelm._validation import parse_list, parse_single + +M = TypeVar("M", bound=BaseModel) + +# Explicit primitive-only param dict avoids mypy's ``disallow_any_explicit`` +# in strict mode while still accepting the shapes httpx serialises for us. +# List values serialise as repeated query keys (``status=a&status=b``). +_ParamValue = str | int | bool | list[str] | None +_ParamDict = dict[str, _ParamValue] + + +def _format_date(value: date | datetime | str) -> str: + """Normalise ``from``/``to`` calendar params to the ISO ``yyyy-MM-dd`` + the API expects. ``datetime`` is truncated to its calendar day because + the endpoints reject full timestamps. + """ + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + return value + + +def _window_params( + period: str, from_: date | datetime | str | None, to: date | datetime | str | None +) -> _ParamDict: + """Pack the shared uptime-window params. ``period`` is always sent (the + API documents that an explicit ``from``/``to`` window wins over it when + both are supplied, so forwarding the default is harmless). + """ + params: _ParamDict = {"period": period} + if from_ is not None: + params["from"] = _format_date(from_) + if to is not None: + params["to"] = _format_date(to) + return params + + +_BASE = "/api/v1/services" + + +def _service_path(slug_or_id: str) -> str: + return f"{_BASE}/{path_param(slug_or_id)}" + + +class Services: + """Status Data catalog: vendor services, components, incidents, uptime.""" + + def __init__(self, client: httpx.Client) -> None: + self._client = client + + def _fetch_table( + self, path: str, model_class: type[M], params: _ParamDict | None = None + ) -> list[M]: + """Catalog list endpoints return the offset-page envelope + ``{data, hasNext, hasPrev, …}`` but are not actually paginated + server-side (no ``page``/``size`` params) — unwrap the envelope and + hand back the validated items directly. + """ + resp = api_get(self._client, path, params=params or None) + envelope = _validate_page(resp) + return parse_list(model_class, envelope.data, f"GET {path}") + + def list( + self, + *, + category: str | None = None, + status: str | None = None, + search: str | None = None, + cursor: str | None = None, + limit: int = 20, + ) -> CursorPage[ServiceCatalogDto]: + """List catalog services (cursor-paginated). + + Optional server-side filters mirror the documented + ``GET /api/v1/services`` query params: ``category`` (exact + category name), ``status`` (current overall status, e.g. + ``"operational"``), and ``search`` (free-text match on name/slug). + Pass ``cursor`` from a previous page's ``next_cursor`` to continue. + """ + filters: _ParamDict = {} + if category is not None: + filters["category"] = category + if status is not None: + filters["status"] = status + if search is not None: + filters["search"] = search + return fetch_cursor_page( + self._client, + _BASE, + ServiceCatalogDto, + cursor=cursor, + limit=limit, + extra_params=filters, + ) + + def get(self, slug_or_id: str, *, summary: bool = False) -> ServiceDetailDto: + """Get a service's detail view by slug or ID. + + ``summary=True`` requests the trimmed payload (component groups + without leaf children) used by list-style consumers. + """ + params: _ParamDict | None = {"summary": True} if summary else None + return parse_single( + ServiceDetailDto, + api_get(self._client, _service_path(slug_or_id), params=params), + f"GET {_BASE}/{slug_or_id}", + ) + + def live_status(self, slug_or_id: str) -> ServiceLiveStatusDto: + """Get a service's current live status (overall + per-component).""" + return parse_single( + ServiceLiveStatusDto, + api_get(self._client, f"{_service_path(slug_or_id)}/live-status"), + f"GET {_BASE}/{slug_or_id}/live-status", + ) + + # ``builtins.list`` below because the ``list`` *method* shadows the + # builtin inside the class body for everything defined after it. + def categories(self) -> builtins.list[CategoryDto]: + """List all service categories with their service counts.""" + return self._fetch_table("/api/v1/categories", CategoryDto) + + def summary(self) -> GlobalStatusSummaryDto: + """Get the global status summary across the whole catalog.""" + return parse_single( + GlobalStatusSummaryDto, + api_get(self._client, f"{_BASE}/summary"), + f"GET {_BASE}/summary", + ) + + def components( + self, slug_or_id: str, *, group_id: str | None = None + ) -> builtins.list[ServiceComponentDto]: + """List a service's active components. + + ``group_id`` restricts the result to direct children of that group + component. + """ + params: _ParamDict = {} + if group_id is not None: + params["groupId"] = group_id + return self._fetch_table( + f"{_service_path(slug_or_id)}/components", ServiceComponentDto, params + ) + + def component_uptime( + self, + slug_or_id: str, + component_id: str, + *, + period: str = "30d", + from_: date | datetime | str | None = None, + to: date | datetime | str | None = None, + ) -> builtins.list[ComponentUptimeDayDto]: + """Get daily uptime data for one component. + + Pass either a preset ``period`` (``7d``, ``30d``, ``90d``, ``1y``) + or an explicit ``from_``/``to`` calendar window (ISO ``yyyy-MM-dd``, + max 730 days; ``to`` defaults to today). The explicit window wins + when both are supplied. + """ + return self._fetch_table( + f"{_service_path(slug_or_id)}/components/{path_param(component_id)}/uptime", + ComponentUptimeDayDto, + _window_params(period, from_, to), + ) + + def batch_component_uptime( + self, + slug_or_id: str, + *, + period: str = "30d", + from_: date | datetime | str | None = None, + to: date | datetime | str | None = None, + ) -> BatchComponentUptimeDto: + """Get daily uptime for every leaf component in a single request, + keyed by component ID. + + Accepts the same window kwargs as :meth:`component_uptime`. + """ + return parse_single( + BatchComponentUptimeDto, + api_get( + self._client, + f"{_service_path(slug_or_id)}/components/uptime", + params=_window_params(period, from_, to), + ), + f"GET {_BASE}/{slug_or_id}/components/uptime", + ) + + def day(self, slug_or_id: str, date: date | str) -> ServiceDayDetailDto: + """Get the per-component rollup for one UTC calendar day + (ISO ``yyyy-MM-dd``). + """ + return parse_single( + ServiceDayDetailDto, + api_get( + self._client, + f"{_service_path(slug_or_id)}/days/{path_param(_format_date(date))}", + ), + f"GET {_BASE}/{slug_or_id}/days/{date}", + ) + + def incidents( + self, + slug_or_id: str | None = None, + *, + status: str | None = None, + from_: date | datetime | str | None = None, + category: str | None = None, + page: int = 0, + size: int = 20, + ) -> Page[ServiceIncidentDto]: + """List vendor incidents (paginated). + + With ``slug_or_id``, lists incidents for that one service; without + it, lists incidents across the whole catalog. ``status`` filters by + incident status (e.g. ``"resolved"``), ``from_`` bounds the window + start, and ``category`` (cross-service mode only) restricts to one + service category. + """ + if slug_or_id is None: + path = f"{_BASE}/incidents" + else: + path = f"{_service_path(slug_or_id)}/incidents" + params: _ParamDict = {"page": page, "size": size} + if status is not None: + params["status"] = status + if from_ is not None: + params["from"] = _format_date(from_) + if category is not None: + params["category"] = category + + resp = api_get(self._client, path, params=params) + envelope = _validate_page(resp) + return Page( + data=parse_list(ServiceIncidentDto, envelope.data, f"GET {path}"), + has_next=envelope.hasNext, + has_prev=envelope.hasPrev, + total_elements=envelope.totalElements, + total_pages=envelope.totalPages, + ) + + def incident(self, slug_or_id: str, incident_id: str) -> ServiceIncidentDetailDto: + """Get one vendor incident with its full update timeline.""" + return parse_single( + ServiceIncidentDetailDto, + api_get( + self._client, + f"{_service_path(slug_or_id)}/incidents/{path_param(incident_id)}", + ), + f"GET {_BASE}/{slug_or_id}/incidents/{incident_id}", + ) + + def uptime( + self, slug_or_id: str, *, period: str = "30d", granularity: str = "daily" + ) -> ServiceUptimeResponse: + """Get a service's uptime with per-bucket breakdown. + + ``period`` is a preset window (``7d``, ``30d``, ``90d``, ``1y``); + ``granularity`` is ``"hourly"`` or ``"daily"``. + """ + return parse_single( + ServiceUptimeResponse, + api_get( + self._client, + f"{_service_path(slug_or_id)}/uptime", + params={"period": period, "granularity": granularity}, + ), + f"GET {_BASE}/{slug_or_id}/uptime", + ) + + def maintenances( + self, slug_or_id: str, *, status: str | builtins.list[str] | None = None + ) -> builtins.list[ScheduledMaintenanceDto]: + """List a service's scheduled maintenances. + + ``status`` filters by maintenance status (``scheduled``, + ``in_progress``, ``completed``); pass a list to match several. + """ + params: _ParamDict = {} + if status is not None: + params["status"] = status + return self._fetch_table( + f"{_service_path(slug_or_id)}/maintenances", ScheduledMaintenanceDto, params + ) diff --git a/src/devhelm/types.py b/src/devhelm/types.py index 46f1fd6..0353a83 100644 --- a/src/devhelm/types.py +++ b/src/devhelm/types.py @@ -68,6 +68,7 @@ from devhelm._enums import ResourceGroupDtoHealthThresholdType as HealthThresholdType from devhelm._enums import ResourceGroupHealthDtoStatus as ResourceGroupHealthStatus from devhelm._enums import ResourceGroupHealthDtoThresholdStatus as ThresholdStatus +from devhelm._enums import ServiceCatalogDtoLifecycleStatus as LifecycleStatus from devhelm._enums import ServiceSubscriptionDtoAlertSensitivity as AlertSensitivity from devhelm._enums import ( StatusPageComponentDtoCurrentStatus as StatusPageComponentCurrentStatus, @@ -104,8 +105,11 @@ ApiKeyCreateResponse, ApiKeyDto, AssertionTestResultDto, + BatchComponentUptimeDto, + CategoryDto, CheckResultDto, # noqa: F401 CheckTraceDto, + ComponentUptimeDayDto, CreateAlertChannelRequest, CreateApiKeyRequest, CreateEnvironmentRequest, @@ -125,6 +129,7 @@ DashboardOverviewDto, DeployLockDto, EnvironmentDto, + GlobalStatusSummaryDto, IncidentDetailDto, IncidentDto, IncidentStateTransitionDto, @@ -142,8 +147,18 @@ ResourceGroupDto, ResourceGroupMemberDto, # noqa: F401 RuleEvaluationDto, + ScheduledMaintenanceDto, SecretDto, + ServiceCatalogDto, + ServiceComponentDto, + ServiceDayDetailDto, + ServiceDetailDto, + ServiceIncidentDetailDto, + ServiceIncidentDto, + ServiceLiveStatusDto, + ServiceSubscribeRequest, ServiceSubscriptionDto, + ServiceUptimeResponse, StatusPageBranding, StatusPageComponentDto, StatusPageComponentGroupDto, @@ -156,6 +171,7 @@ TagDto, TestChannelResult, UpdateAlertChannelRequest, + UpdateAlertSensitivityRequest, UpdateEnvironmentRequest, UpdateMaintenanceWindowRequest, UpdateMonitorRequest, @@ -183,8 +199,11 @@ "ApiKeyCreateResponse", "ApiKeyDto", "AssertionTestResultDto", + "BatchComponentUptimeDto", + "CategoryDto", "CheckResultDto", "CheckTraceDto", + "ComponentUptimeDayDto", "CreateAlertChannelRequest", "CreateApiKeyRequest", "CreateEnvironmentRequest", @@ -204,6 +223,7 @@ "DashboardOverviewDto", "DeployLockDto", "EnvironmentDto", + "GlobalStatusSummaryDto", "IncidentDetailDto", "IncidentDto", "IncidentStateTransitionDto", @@ -220,8 +240,18 @@ "ResourceGroupDto", "ResourceGroupMemberDto", "RuleEvaluationDto", + "ScheduledMaintenanceDto", "SecretDto", + "ServiceCatalogDto", + "ServiceComponentDto", + "ServiceDayDetailDto", + "ServiceDetailDto", + "ServiceIncidentDetailDto", + "ServiceIncidentDto", + "ServiceLiveStatusDto", + "ServiceSubscribeRequest", "ServiceSubscriptionDto", + "ServiceUptimeResponse", "StatusPageBranding", "StatusPageComponentDto", "StatusPageComponentGroupDto", @@ -234,6 +264,7 @@ "TagDto", "TestChannelResult", "UpdateAlertChannelRequest", + "UpdateAlertSensitivityRequest", "UpdateEnvironmentRequest", "UpdateMaintenanceWindowRequest", "UpdateMonitorRequest", @@ -272,6 +303,7 @@ "IncidentSeverity", "IncidentStatus", "IncidentUpdateCreatedBy", + "LifecycleStatus", "LinkedIncidentStatus", "ManagedBy", "MemberStatus", diff --git a/tests/test_client.py b/tests/test_client.py index 80e9b0b..465dbef 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,6 +19,7 @@ from devhelm.resources.notification_policies import NotificationPolicies from devhelm.resources.resource_groups import ResourceGroups from devhelm.resources.secrets import Secrets +from devhelm.resources.services import Services from devhelm.resources.status import Status from devhelm.resources.status_pages import StatusPages from devhelm.resources.tags import Tags @@ -74,6 +75,24 @@ def test_api_keys(self, client: Devhelm) -> None: def test_dependencies(self, client: Devhelm) -> None: assert isinstance(client.dependencies, Dependencies) + assert callable(client.dependencies.track) + assert callable(client.dependencies.update_alert_sensitivity) + + def test_services(self, client: Devhelm) -> None: + assert isinstance(client.services, Services) + assert callable(client.services.list) + assert callable(client.services.get) + assert callable(client.services.live_status) + assert callable(client.services.categories) + assert callable(client.services.summary) + assert callable(client.services.components) + assert callable(client.services.component_uptime) + assert callable(client.services.batch_component_uptime) + assert callable(client.services.day) + assert callable(client.services.incidents) + assert callable(client.services.incident) + assert callable(client.services.uptime) + assert callable(client.services.maintenances) def test_deploy_lock(self, client: Devhelm) -> None: assert isinstance(client.deploy_lock, DeployLock) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..bfd15b6 --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,142 @@ +"""Tests for the ``Dependencies`` resource module (service subscriptions). + +Same shape as ``test_maintenance_windows``: a capturing +``httpx.MockTransport`` plus assertions on the wire-level method, URL, and +JSON body — locking in the contract that ``track`` only sends a body when +the caller actually provided subscription options. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from devhelm.resources.dependencies import Dependencies + +_SUBSCRIPTION = { + "subscriptionId": "11111111-1111-1111-1111-111111111111", + "serviceId": "22222222-2222-2222-2222-222222222222", + "slug": "github", + "name": "GitHub", + "adapterType": "statuspage", + "pollingIntervalSeconds": 300, + "enabled": True, + "alertSensitivity": "AWARENESS", + "subscribedAt": "2026-06-01T00:00:00Z", +} + + +def _stub_transport(captured: list[httpx.Request]) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + method = request.method + path = request.url.path + if method == "POST" and path.startswith("/api/v1/service-subscriptions/"): + return httpx.Response(201, json={"data": _SUBSCRIPTION}) + if method == "PATCH" and path.endswith("/alert-sensitivity"): + return httpx.Response( + 200, json={"data": {**_SUBSCRIPTION, "alertSensitivity": "MAJOR_ONLY"}} + ) + raise AssertionError(f"unexpected {method} {path}") + + return httpx.MockTransport(handler) + + +def _resource(transport: httpx.MockTransport) -> Dependencies: + http_client = httpx.Client(transport=transport, base_url="http://localhost:8080") + return Dependencies(http_client) + + +# --------------------------------------------------------------------------- +# track — body presence/shape contract +# --------------------------------------------------------------------------- + + +class TestTrack: + def test_track_without_options_sends_no_body(self) -> None: + """The bare ``track(slug)`` call predates the optional body — it + must keep producing a body-less POST so the API's defaults apply + unchanged (whole-service subscription, AWARENESS sensitivity). + """ + captured: list[httpx.Request] = [] + deps = _resource(_stub_transport(captured)) + + result = deps.track("github") + + assert len(captured) == 1 + request = captured[0] + assert request.method == "POST" + assert request.url.path == "/api/v1/service-subscriptions/github" + assert request.content == b"" + assert result.slug == "github" + + def test_track_with_options_sends_json_body(self) -> None: + captured: list[httpx.Request] = [] + deps = _resource(_stub_transport(captured)) + + deps.track( + "github", + component_id="33333333-3333-3333-3333-333333333333", + alert_sensitivity="INCIDENTS_ONLY", + ) + + assert len(captured) == 1 + body = json.loads(captured[0].content) + assert body == { + "componentId": "33333333-3333-3333-3333-333333333333", + "alertSensitivity": "INCIDENTS_ONLY", + } + + def test_track_with_only_sensitivity_omits_component_key(self) -> None: + captured: list[httpx.Request] = [] + deps = _resource(_stub_transport(captured)) + + deps.track("github", alert_sensitivity="ALL") + + body = json.loads(captured[0].content) + # ``componentId`` must be absent (not ``null``) so the API treats + # this as a whole-service subscription. + assert body == {"alertSensitivity": "ALL"} + + def test_track_rejects_invalid_sensitivity_before_http(self) -> None: + captured: list[httpx.Request] = [] + deps = _resource(_stub_transport(captured)) + + with pytest.raises(Exception, match="Request validation failed"): + deps.track("github", alert_sensitivity="wrong") + assert captured == [] + + +# --------------------------------------------------------------------------- +# update_alert_sensitivity — verb, URL, body, envelope unwrap +# --------------------------------------------------------------------------- + + +class TestUpdateAlertSensitivity: + def test_update_patches_alert_sensitivity_endpoint(self) -> None: + captured: list[httpx.Request] = [] + deps = _resource(_stub_transport(captured)) + + result = deps.update_alert_sensitivity( + "11111111-1111-1111-1111-111111111111", "MAJOR_ONLY" + ) + + assert len(captured) == 1 + request = captured[0] + assert request.method == "PATCH" + assert request.url.path == ( + "/api/v1/service-subscriptions/" + "11111111-1111-1111-1111-111111111111/alert-sensitivity" + ) + assert json.loads(request.content) == {"alertSensitivity": "MAJOR_ONLY"} + assert result.alert_sensitivity == "MAJOR_ONLY" + + def test_update_rejects_invalid_sensitivity_before_http(self) -> None: + captured: list[httpx.Request] = [] + deps = _resource(_stub_transport(captured)) + + with pytest.raises(Exception, match="Request validation failed"): + deps.update_alert_sensitivity("11111111", "sometimes") + assert captured == [] diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..0574463 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,281 @@ +"""Tests for the ``Services`` resource module (Status Data catalog). + +Mirrors ``test_maintenance_windows``: spin up an ``httpx.MockTransport``, +point a real ``Services`` instance at it, and assert the resulting +``httpx.Request`` carries the wire-level URL, method, and query string the +API documents — plus that responses are unwrapped into typed models. +""" + +from __future__ import annotations + +import httpx + +from devhelm.resources.services import Services + +# --------------------------------------------------------------------------- +# Fixtures: canned API payloads (camelCase wire shape) +# --------------------------------------------------------------------------- + + +_SERVICE = { + "id": "11111111-1111-1111-1111-111111111111", + "slug": "github", + "name": "GitHub", + "category": "Developer Tools", + "adapterType": "statuspage", + "pollingIntervalSeconds": 300, + "lifecycleStatus": "ACTIVE", + "enabled": True, + "published": True, + "overallStatus": "operational", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-06-01T00:00:00Z", + "componentCount": 5, + "activeIncidentCount": 0, + "dataCompleteness": "full", +} + +_SERVICE_DETAIL = { + "id": "11111111-1111-1111-1111-111111111111", + "slug": "github", + "name": "GitHub", + "adapterType": "statuspage", + "pollingIntervalSeconds": 300, + "lifecycleStatus": "ACTIVE", + "enabled": True, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-06-01T00:00:00Z", + "recentIncidents": [], + "components": [], + "activeMaintenances": [], + "dataCompleteness": "full", +} + +_INCIDENT = { + "id": "33333333-3333-3333-3333-333333333333", + "serviceId": "11111111-1111-1111-1111-111111111111", + "serviceSlug": "github", + "title": "Elevated API error rates", + "status": "resolved", +} + + +def _table(items: list[dict[str, object]]) -> dict[str, object]: + return {"data": items, "hasNext": False, "hasPrev": False} + + +def _stub_transport(captured: list[httpx.Request]) -> httpx.MockTransport: + """Capture every outgoing request and return JSON shaped like the API. + + Routes on method + path so a single transport serves every test below; + routing mistakes surface loudly instead of silently 404'ing. + """ + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + path = request.url.path + if request.method != "GET": + raise AssertionError(f"unexpected {request.method} {path}") + if path == "/api/v1/services": + return httpx.Response( + 200, json={"data": [_SERVICE], "nextCursor": "abc123", "hasMore": True} + ) + if path == "/api/v1/categories": + return httpx.Response( + 200, json=_table([{"category": "Developer Tools", "serviceCount": 12}]) + ) + if path == "/api/v1/services/incidents" or path.endswith("/incidents"): + return httpx.Response(200, json=_table([_INCIDENT])) + if path.endswith("/live-status"): + return httpx.Response( + 200, + json={ + "data": { + "overallStatus": "operational", + "componentStatuses": [], + "activeIncidentCount": 0, + } + }, + ) + if path.endswith("/uptime"): + return httpx.Response( + 200, + json={ + "data": { + "overallUptimePct": 99.95, + "period": "30d", + "granularity": "daily", + "buckets": [], + } + }, + ) + if path == "/api/v1/services/github": + return httpx.Response(200, json={"data": _SERVICE_DETAIL}) + raise AssertionError(f"unexpected GET {path}") + + return httpx.MockTransport(handler) + + +def _resource(transport: httpx.MockTransport) -> Services: + http_client = httpx.Client(transport=transport, base_url="http://localhost:8080") + return Services(http_client) + + +# --------------------------------------------------------------------------- +# list — cursor pagination + filter threading +# --------------------------------------------------------------------------- + + +class TestList: + def test_list_threads_filters_to_query_string(self) -> None: + captured: list[httpx.Request] = [] + services = _resource(_stub_transport(captured)) + + services.list( + category="Developer Tools", + status="operational", + search="git", + cursor="cursor-1", + limit=50, + ) + + assert len(captured) == 1 + params = captured[0].url.params + assert params["category"] == "Developer Tools" + assert params["status"] == "operational" + assert params["search"] == "git" + assert params["cursor"] == "cursor-1" + assert params["limit"] == "50" + + def test_list_omits_unspecified_filters(self) -> None: + captured: list[httpx.Request] = [] + services = _resource(_stub_transport(captured)) + + services.list() + + assert len(captured) == 1 + params = captured[0].url.params + assert "category" not in params + assert "status" not in params + assert "search" not in params + assert "cursor" not in params + # The default page size is always sent so server defaults can't + # silently drift under the SDK's documented signature. + assert params["limit"] == "20" + + def test_list_returns_cursor_page(self) -> None: + captured: list[httpx.Request] = [] + services = _resource(_stub_transport(captured)) + + page = services.list(search="git") + + assert len(page.data) == 1 + assert page.data[0].slug == "github" + assert page.data[0].lifecycle_status == "ACTIVE" + assert page.next_cursor == "abc123" + assert page.has_more is True + + +# --------------------------------------------------------------------------- +# get — URL templating, summary flag, envelope unwrap +# --------------------------------------------------------------------------- + + +class TestGet: + def test_get_hits_resource_url_and_unwraps(self) -> None: + captured: list[httpx.Request] = [] + services = _resource(_stub_transport(captured)) + + result = services.get("github") + + assert len(captured) == 1 + assert captured[0].url.path == "/api/v1/services/github" + # Default mode must not send ``summary`` so the API's full payload + # behaviour applies. + assert "summary" not in captured[0].url.params + assert result.slug == "github" + assert result.adapter_type == "statuspage" + + def test_get_summary_flag_reaches_wire(self) -> None: + captured: list[httpx.Request] = [] + services = _resource(_stub_transport(captured)) + + services.get("github", summary=True) + + assert captured[0].url.params["summary"] == "true" + + +# --------------------------------------------------------------------------- +# incidents — dual-mode routing (per-service vs cross-service) +# --------------------------------------------------------------------------- + + +class TestIncidents: + def test_incidents_with_slug_hits_per_service_endpoint(self) -> None: + captured: list[httpx.Request] = [] + services = _resource(_stub_transport(captured)) + + page = services.incidents("github", status="resolved", from_="2026-05-01") + + assert len(captured) == 1 + request = captured[0] + assert request.url.path == "/api/v1/services/github/incidents" + assert request.url.params["status"] == "resolved" + assert request.url.params["from"] == "2026-05-01" + assert request.url.params["page"] == "0" + assert request.url.params["size"] == "20" + assert len(page.data) == 1 + assert page.data[0].title == "Elevated API error rates" + + def test_incidents_without_slug_hits_cross_service_endpoint(self) -> None: + captured: list[httpx.Request] = [] + services = _resource(_stub_transport(captured)) + + services.incidents(category="Developer Tools", page=2, size=50) + + assert len(captured) == 1 + request = captured[0] + assert request.url.path == "/api/v1/services/incidents" + assert request.url.params["category"] == "Developer Tools" + assert request.url.params["page"] == "2" + assert request.url.params["size"] == "50" + + +# --------------------------------------------------------------------------- +# Singleton + table endpoints — URL contract and envelope handling +# --------------------------------------------------------------------------- + + +class TestSingleAndTableEndpoints: + def test_live_status(self) -> None: + captured: list[httpx.Request] = [] + services = _resource(_stub_transport(captured)) + + result = services.live_status("github") + + assert captured[0].url.path == "/api/v1/services/github/live-status" + assert result.overall_status == "operational" + + def test_categories_unwraps_table_envelope(self) -> None: + captured: list[httpx.Request] = [] + services = _resource(_stub_transport(captured)) + + result = services.categories() + + assert captured[0].url.path == "/api/v1/categories" + # Not a paginated endpoint — no page/size noise on the wire. + assert "page" not in captured[0].url.params + assert len(result) == 1 + assert result[0].category == "Developer Tools" + assert result[0].service_count == 12 + + def test_uptime_sends_period_and_granularity(self) -> None: + captured: list[httpx.Request] = [] + services = _resource(_stub_transport(captured)) + + result = services.uptime("github", period="7d", granularity="hourly") + + assert captured[0].url.path == "/api/v1/services/github/uptime" + assert captured[0].url.params["period"] == "7d" + assert captured[0].url.params["granularity"] == "hourly" + assert result.overall_uptime_pct == 99.95 diff --git a/tests/test_spec_parity.py b/tests/test_spec_parity.py index 735fb30..759521e 100644 --- a/tests/test_spec_parity.py +++ b/tests/test_spec_parity.py @@ -146,7 +146,9 @@ def test_public_dto_exists_in_spec(dto_name: str, schemas: dict[str, Any]) -> No "CreateWebhookEndpointRequest", "ReorderComponentsRequest", "ResolveIncidentRequest", + "ServiceSubscribeRequest", "UpdateAlertChannelRequest", + "UpdateAlertSensitivityRequest", "UpdateEnvironmentRequest", "UpdateMaintenanceWindowRequest", "UpdateMonitorRequest", @@ -379,6 +381,8 @@ def test_resource_method_body_accepts_dict( "/api/v1/deploy/lock", "/api/v1/status-pages", "/api/v1/service-subscriptions", + "/api/v1/services", + "/api/v1/categories", ] diff --git a/uv.lock b/uv.lock index f039a58..00830d6 100644 --- a/uv.lock +++ b/uv.lock @@ -331,7 +331,7 @@ wheels = [ [[package]] name = "devhelm" -version = "1.0.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "httpx" },