hyperping 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hyperping/client.py ADDED
@@ -0,0 +1,452 @@
1
+ """Hyperping API client with retry logic and error handling.
2
+
3
+ This module provides the main :class:`HyperpingClient` class along with
4
+ configuration dataclasses for retry and circuit-breaker behavior.
5
+ """
6
+
7
+ import logging
8
+ import random
9
+ import threading
10
+ import time
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+ from typing import Any
14
+
15
+ import httpx
16
+ from pydantic import SecretStr
17
+
18
+ from hyperping._incidents_mixin import IncidentsMixin
19
+ from hyperping._maintenance_mixin import MaintenanceMixin
20
+ from hyperping._monitors_mixin import MonitorsMixin
21
+ from hyperping._outages_mixin import OutagesMixin
22
+ from hyperping._statuspages_mixin import StatusPagesMixin
23
+ from hyperping._version import __version__
24
+ from hyperping.endpoints import (
25
+ API_BASE,
26
+ )
27
+ from hyperping.exceptions import (
28
+ HyperpingAPIError,
29
+ HyperpingAuthError,
30
+ HyperpingNotFoundError,
31
+ HyperpingRateLimitError,
32
+ HyperpingValidationError,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _DEFAULT_USER_AGENT = f"hyperping-python/{__version__}"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RetryConfig:
42
+ """Configuration for retry behavior."""
43
+
44
+ max_retries: int = 3
45
+ initial_delay: float = 1.0
46
+ max_delay: float = 30.0
47
+ backoff_factor: float = 2.0
48
+ retry_on_status: tuple[int, ...] = (429, 500, 502, 503, 504)
49
+
50
+
51
+ DEFAULT_RETRY_CONFIG = RetryConfig()
52
+
53
+ # Maximum time to honor a server-requested Retry-After value (5 minutes)
54
+ _RETRY_AFTER_MAX = 300.0
55
+
56
+
57
+ class CircuitState(StrEnum):
58
+ """Circuit breaker states."""
59
+
60
+ CLOSED = "closed" # Normal: requests flow through
61
+ OPEN = "open" # Failing: requests fail fast
62
+ HALF_OPEN = "half_open" # Testing: one request allowed through
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class CircuitBreakerConfig:
67
+ """Configuration for circuit breaker behavior."""
68
+
69
+ failure_threshold: int = 5
70
+ recovery_timeout: float = 60.0
71
+ half_open_max_calls: int = 1
72
+
73
+
74
+ DEFAULT_CIRCUIT_BREAKER_CONFIG = CircuitBreakerConfig()
75
+
76
+
77
+ class CircuitBreaker:
78
+ """Circuit breaker pattern for API calls.
79
+
80
+ States:
81
+ CLOSED → normal operation
82
+ OPEN → fail fast, no API calls made
83
+ HALF_OPEN → allow one trial call; success → CLOSED, failure → OPEN
84
+ """
85
+
86
+ def __init__(self, config: CircuitBreakerConfig | None = None) -> None:
87
+ """Initialize the circuit breaker.
88
+
89
+ Args:
90
+ config: Circuit breaker configuration. Uses defaults if ``None``.
91
+ """
92
+ self._config = config or DEFAULT_CIRCUIT_BREAKER_CONFIG
93
+ self._state = CircuitState.CLOSED
94
+ self._failure_count = 0
95
+ self._half_open_calls: int = 0
96
+ self._last_failure_time: float | None = None
97
+ self._lock = threading.Lock()
98
+
99
+ @property
100
+ def state(self) -> str:
101
+ """Return the current circuit state."""
102
+ return self._state
103
+
104
+ @property
105
+ def failure_count(self) -> int:
106
+ """Return the current consecutive failure count."""
107
+ return self._failure_count
108
+
109
+ def call_allowed(self) -> bool:
110
+ """Check whether a new call is permitted under current state.
111
+
112
+ Returns:
113
+ ``True`` if a request may proceed, ``False`` if the circuit is open.
114
+ """
115
+ with self._lock:
116
+ if self._state == CircuitState.CLOSED:
117
+ return True
118
+ if self._state == CircuitState.OPEN:
119
+ if self._last_failure_time is not None:
120
+ elapsed = time.time() - self._last_failure_time
121
+ if elapsed >= self._config.recovery_timeout:
122
+ self._state = CircuitState.HALF_OPEN
123
+ logger.info("Circuit breaker: OPEN → HALF_OPEN (trial call allowed)")
124
+ return True
125
+ return False
126
+ # HALF_OPEN: allow only up to half_open_max_calls trial requests
127
+ if self._half_open_calls < self._config.half_open_max_calls:
128
+ self._half_open_calls += 1
129
+ return True
130
+ return False
131
+
132
+ def record_success(self) -> None:
133
+ """Record a successful call — reset to CLOSED."""
134
+ with self._lock:
135
+ self._half_open_calls = 0
136
+ if self._state != CircuitState.CLOSED:
137
+ logger.info(
138
+ f"Circuit breaker: {self._state} → CLOSED (recovered after "
139
+ f"{self._failure_count} failures)"
140
+ )
141
+ self._state = CircuitState.CLOSED
142
+ self._failure_count = 0
143
+ self._last_failure_time = None
144
+
145
+ def record_failure(self) -> None:
146
+ """Record a failed call — may open the circuit."""
147
+ with self._lock:
148
+ self._half_open_calls = 0
149
+ self._failure_count += 1
150
+ self._last_failure_time = time.time()
151
+ if self._failure_count >= self._config.failure_threshold:
152
+ if self._state != CircuitState.OPEN:
153
+ logger.warning(
154
+ f"Circuit breaker: {self._state} → OPEN "
155
+ f"(threshold {self._config.failure_threshold} reached)"
156
+ )
157
+ self._state = CircuitState.OPEN
158
+
159
+
160
+ class HyperpingClient(
161
+ MonitorsMixin, IncidentsMixin, MaintenanceMixin, OutagesMixin, StatusPagesMixin
162
+ ):
163
+ """Client for interacting with Hyperping API.
164
+
165
+ Handles authentication, retry logic, and error mapping.
166
+
167
+ Example:
168
+ >>> client = HyperpingClient(api_key="sk_xxx")
169
+ >>> monitors = client.list_monitors()
170
+ >>> for m in monitors:
171
+ ... print(f"{m.name}: {'down' if m.down else 'up'}")
172
+ """
173
+
174
+ DEFAULT_BASE_URL = API_BASE # https://api.hyperping.io
175
+ DEFAULT_TIMEOUT = 30.0
176
+
177
+ def __init__(
178
+ self,
179
+ api_key: str | SecretStr,
180
+ base_url: str | None = None,
181
+ timeout: float = DEFAULT_TIMEOUT,
182
+ retry_config: RetryConfig | None = None,
183
+ circuit_breaker_config: CircuitBreakerConfig | None = None,
184
+ user_agent: str | None = None,
185
+ ) -> None:
186
+ """Initialize the Hyperping API client.
187
+
188
+ Args:
189
+ api_key: Hyperping API key (starts with ``sk_``). Accepts a plain
190
+ string or a :class:`pydantic.SecretStr`.
191
+ base_url: Override the default API base URL
192
+ (``https://api.hyperping.io``).
193
+ timeout: HTTP request timeout in seconds.
194
+ retry_config: Retry behavior configuration. Pass ``None`` for defaults
195
+ (3 retries, exponential backoff).
196
+ circuit_breaker_config: Circuit breaker configuration. Pass ``None``
197
+ for defaults (5-failure threshold, 60 s recovery).
198
+ user_agent: Custom ``User-Agent`` header value. Defaults to
199
+ ``hyperping-python/0.1.0``.
200
+ """
201
+ self._api_key = SecretStr(api_key) if isinstance(api_key, str) else api_key
202
+ self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
203
+ self.timeout = timeout
204
+ self.retry_config = retry_config or DEFAULT_RETRY_CONFIG
205
+ self._circuit_breaker = CircuitBreaker(circuit_breaker_config)
206
+
207
+ self._client = httpx.Client(
208
+ base_url=self.base_url,
209
+ headers={
210
+ "Authorization": f"Bearer {self._api_key.get_secret_value()}",
211
+ "Content-Type": "application/json",
212
+ "Accept": "application/json",
213
+ "User-Agent": user_agent or _DEFAULT_USER_AGENT,
214
+ },
215
+ timeout=self.timeout,
216
+ )
217
+
218
+ def __repr__(self) -> str:
219
+ return f"HyperpingClient(base_url={self.base_url!r})"
220
+
221
+ def close(self) -> None:
222
+ """Close the HTTP client."""
223
+ self._client.close()
224
+
225
+ def __enter__(self) -> "HyperpingClient":
226
+ return self
227
+
228
+ def __exit__(self, *args: Any) -> None:
229
+ self.close()
230
+
231
+ @property
232
+ def circuit_breaker(self) -> CircuitBreaker:
233
+ """Access the circuit breaker state (for monitoring)."""
234
+ return self._circuit_breaker
235
+
236
+ def _handle_response_error(self, response: httpx.Response) -> None:
237
+ """Map HTTP errors to typed exceptions.
238
+
239
+ Extracts ``x-request-id`` from response headers when present and
240
+ attaches it to the raised exception for diagnostic context.
241
+
242
+ Args:
243
+ response: The HTTP response with a 4xx or 5xx status code.
244
+
245
+ Raises:
246
+ HyperpingAuthError: On 401 or 403.
247
+ HyperpingNotFoundError: On 404.
248
+ HyperpingRateLimitError: On 429.
249
+ HyperpingValidationError: On 400 or 422.
250
+ HyperpingAPIError: On all other error status codes.
251
+ """
252
+ status = response.status_code
253
+ request_id = response.headers.get("x-request-id")
254
+
255
+ try:
256
+ body = response.json()
257
+ except Exception:
258
+ body = {"error": response.text or "Unknown error"}
259
+
260
+ error_msg = body.get("error") or body.get("message") or f"HTTP {status}"
261
+
262
+ if status == 401 or status == 403:
263
+ raise HyperpingAuthError(
264
+ message=f"Authentication failed: {error_msg}",
265
+ status_code=status,
266
+ response_body=body,
267
+ request_id=request_id,
268
+ )
269
+ elif status == 404:
270
+ raise HyperpingNotFoundError(
271
+ message=f"Resource not found: {error_msg}",
272
+ status_code=status,
273
+ response_body=body,
274
+ request_id=request_id,
275
+ )
276
+ elif status == 429:
277
+ retry_after = response.headers.get("Retry-After")
278
+ retry_after_seconds: int | None = None
279
+ if retry_after:
280
+ try:
281
+ retry_after_seconds = int(retry_after)
282
+ except ValueError:
283
+ retry_after_seconds = None
284
+ raise HyperpingRateLimitError(
285
+ message=f"Rate limit exceeded: {error_msg}",
286
+ status_code=status,
287
+ response_body=body,
288
+ retry_after=retry_after_seconds,
289
+ request_id=request_id,
290
+ )
291
+ elif status == 400 or status == 422:
292
+ raise HyperpingValidationError(
293
+ message=f"Validation error: {error_msg}",
294
+ status_code=status,
295
+ response_body=body,
296
+ validation_errors=body.get("details") or body.get("errors"),
297
+ request_id=request_id,
298
+ )
299
+ else:
300
+ raise HyperpingAPIError(
301
+ message=f"API error: {error_msg}",
302
+ status_code=status,
303
+ response_body=body,
304
+ request_id=request_id,
305
+ )
306
+
307
+ def _execute_single_attempt(
308
+ self,
309
+ method: str,
310
+ path: str,
311
+ json: dict[str, Any] | None = None,
312
+ params: dict[str, Any] | None = None,
313
+ ) -> dict[str, Any] | httpx.Response:
314
+ """Execute a single HTTP request attempt.
315
+
316
+ Returns the parsed response dict on success, or the raw Response
317
+ object when the status code indicates a retryable/non-retryable error
318
+ (caller decides whether to retry).
319
+
320
+ Raises:
321
+ httpx.TimeoutException: On request timeout
322
+ httpx.RequestError: On connection/transport errors
323
+ """
324
+ logger.debug(
325
+ f"API request: {method} {path} (attempt)",
326
+ extra={"json": json, "params": params},
327
+ )
328
+
329
+ response = self._client.request(method=method, url=path, json=json, params=params)
330
+
331
+ if response.status_code >= 400:
332
+ return response
333
+
334
+ # Success
335
+ self._circuit_breaker.record_success()
336
+ if response.status_code == 204:
337
+ return {}
338
+ return response.json() # type: ignore[no-any-return] # list endpoints return arrays; callers use isinstance checks
339
+
340
+ def _request(
341
+ self,
342
+ method: str,
343
+ path: str,
344
+ json: dict[str, Any] | None = None,
345
+ params: dict[str, Any] | None = None,
346
+ ) -> dict[str, Any]:
347
+ """Make an HTTP request with retry logic.
348
+
349
+ Args:
350
+ method: HTTP method (GET, POST, PUT, DELETE)
351
+ path: API path (e.g., Endpoint.MONITORS)
352
+ json: Request body as dict
353
+ params: Query parameters
354
+
355
+ Returns:
356
+ Response body as dict
357
+
358
+ Raises:
359
+ HyperpingAPIError: On API errors after retries exhausted
360
+ """
361
+ if not self._circuit_breaker.call_allowed():
362
+ raise HyperpingAPIError(
363
+ f"Circuit breaker OPEN — API calls suspended. "
364
+ f"Consecutive failures: {self._circuit_breaker.failure_count}. "
365
+ f"Will retry after {self.retry_config.initial_delay}s."
366
+ )
367
+
368
+ last_exception: Exception | None = None
369
+ delay = self.retry_config.initial_delay
370
+ max_attempts = self.retry_config.max_retries + 1
371
+
372
+ for attempt in range(max_attempts):
373
+ try:
374
+ result = self._execute_single_attempt(method, path, json, params)
375
+
376
+ if not isinstance(result, httpx.Response):
377
+ return result
378
+
379
+ response = result
380
+ if (
381
+ response.status_code in self.retry_config.retry_on_status
382
+ and attempt < self.retry_config.max_retries
383
+ ):
384
+ if response.status_code == 429:
385
+ retry_after = response.headers.get("Retry-After")
386
+ if retry_after:
387
+ delay = min(float(retry_after), _RETRY_AFTER_MAX)
388
+ sleep_time = delay
389
+ if response.status_code != 429:
390
+ sleep_time = delay + random.uniform(0, delay * 0.25)
391
+ logger.warning(
392
+ f"Retrying after {sleep_time:.2f}s due to {response.status_code} "
393
+ f"(attempt {attempt + 1}/{max_attempts})"
394
+ )
395
+ time.sleep(sleep_time)
396
+ delay = min(
397
+ delay * self.retry_config.backoff_factor,
398
+ self.retry_config.max_delay,
399
+ )
400
+ continue
401
+
402
+ # Only trip circuit breaker on server errors, not client errors
403
+ if response.status_code >= 500:
404
+ self._circuit_breaker.record_failure()
405
+ self._handle_response_error(response)
406
+
407
+ except (httpx.TimeoutException, httpx.RequestError) as e:
408
+ last_exception = e
409
+ if attempt < self.retry_config.max_retries:
410
+ label = "timeout" if isinstance(e, httpx.TimeoutException) else str(e)
411
+ sleep_time = delay + random.uniform(0, delay * 0.25)
412
+ logger.warning(
413
+ f"Request {label}, retrying after {sleep_time:.2f}s "
414
+ f"(attempt {attempt + 1}/{max_attempts})"
415
+ )
416
+ time.sleep(sleep_time)
417
+ delay = min(
418
+ delay * self.retry_config.backoff_factor,
419
+ self.retry_config.max_delay,
420
+ )
421
+ continue
422
+ self._circuit_breaker.record_failure()
423
+ if isinstance(e, httpx.TimeoutException):
424
+ raise HyperpingAPIError(
425
+ f"Request timeout after {max_attempts} attempts"
426
+ ) from e
427
+ raise HyperpingAPIError(f"Request failed: {e}") from e
428
+
429
+ # Should not reach here, but just in case
430
+ raise HyperpingAPIError( # pragma: no cover
431
+ "Request failed after all retries"
432
+ ) from last_exception
433
+
434
+ # ==================== Health Check ====================
435
+
436
+ def ping(self) -> bool:
437
+ """Test API connectivity and authentication.
438
+
439
+ Returns:
440
+ True if connection successful
441
+
442
+ Raises:
443
+ HyperpingAuthError: If authentication fails
444
+ HyperpingAPIError: If connection fails
445
+ """
446
+ try:
447
+ self.list_monitors()
448
+ return True
449
+ except HyperpingAuthError:
450
+ raise
451
+ except (HyperpingAPIError, httpx.RequestError, httpx.TimeoutException) as e:
452
+ raise HyperpingAPIError(f"API connectivity test failed: {e}") from e
hyperping/endpoints.py ADDED
@@ -0,0 +1,231 @@
1
+ """API endpoint definitions - single source of truth for all Hyperping API paths.
2
+
3
+ This module uses frozen dataclasses and StrEnum for type-safe, immutable endpoint
4
+ configuration following modern Python best practices.
5
+
6
+ Usage:
7
+ from hyperping.endpoints import API_BASE, Endpoint
8
+
9
+ # Type-safe endpoint access with IDE autocompletion
10
+ url = f"{API_BASE}{Endpoint.MONITORS}" # https://api.hyperping.io/v1/monitors
11
+ url = f"{API_BASE}{Endpoint.INCIDENTS}" # https://api.hyperping.io/v3/incidents
12
+ url = f"{API_BASE}{Endpoint.MONITORS}/mon_123" # https://api.hyperping.io/v1/monitors/mon_123
13
+
14
+ # Access metadata
15
+ Endpoint.MONITORS.version # "v1"
16
+ Endpoint.MONITORS.resource # "monitors"
17
+ Endpoint.INCIDENTS.version # "v3"
18
+ """
19
+
20
+ from dataclasses import dataclass
21
+ from enum import StrEnum
22
+ from typing import Final
23
+
24
+ # =============================================================================
25
+ # API Base URL - Single Source of Truth
26
+ # =============================================================================
27
+
28
+ API_BASE: Final[str] = "https://api.hyperping.io"
29
+ """Base URL for all Hyperping API requests. Never includes trailing slash."""
30
+
31
+
32
+ # =============================================================================
33
+ # API Versions
34
+ # =============================================================================
35
+
36
+
37
+ class APIVersion(StrEnum):
38
+ """API versions used by Hyperping.
39
+
40
+ Different resources use different API versions. This enum documents
41
+ the available versions and provides type safety.
42
+ """
43
+
44
+ V1 = "v1"
45
+ """Version 1 - Used by monitors and maintenance endpoints."""
46
+
47
+ V2 = "v2"
48
+ """Version 2 - Used by reporting endpoints."""
49
+
50
+ V3 = "v3"
51
+ """Version 3 - Used by incidents endpoints."""
52
+
53
+
54
+ # =============================================================================
55
+ # Endpoint Configuration
56
+ # =============================================================================
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class EndpointConfig:
61
+ """Immutable configuration for an API endpoint.
62
+
63
+ Attributes:
64
+ version: API version (e.g., "v1", "v2", "v3")
65
+ resource: Resource name/path (e.g., "monitors", "incidents")
66
+ description: Human-readable description for documentation
67
+
68
+ Example:
69
+ >>> config = EndpointConfig(version=APIVersion.V1, resource="monitors")
70
+ >>> str(config)
71
+ '/v1/monitors'
72
+ """
73
+
74
+ version: APIVersion
75
+ resource: str
76
+ description: str = ""
77
+
78
+ def __str__(self) -> str:
79
+ """Return the full path for this endpoint."""
80
+ return f"/{self.version}/{self.resource}"
81
+
82
+ @property
83
+ def path(self) -> str:
84
+ """Alias for string representation."""
85
+ return str(self)
86
+
87
+
88
+ # =============================================================================
89
+ # Endpoint Registry - Type-Safe Endpoint Access
90
+ # =============================================================================
91
+
92
+
93
+ class Endpoint(StrEnum):
94
+ """Type-safe endpoint paths for the Hyperping API.
95
+
96
+ This enum provides:
97
+ - IDE autocompletion for endpoint names
98
+ - Type safety (typos cause AttributeError, not silent bugs)
99
+ - Immutability (endpoints cannot be modified at runtime)
100
+ - Self-documenting code
101
+
102
+ Usage:
103
+ # Direct string usage (StrEnum inherits from str)
104
+ url = f"{API_BASE}{Endpoint.MONITORS}"
105
+ url = f"{API_BASE}{Endpoint.MONITORS}/{monitor_id}"
106
+
107
+ # Access version info via ENDPOINTS dict
108
+ version = ENDPOINTS[Endpoint.MONITORS].version
109
+ """
110
+
111
+ # Monitors - v1
112
+ MONITORS = "/v1/monitors"
113
+ """Monitor CRUD operations. Version: v1"""
114
+
115
+ # Maintenance - v1
116
+ MAINTENANCE = "/v1/maintenance-windows"
117
+ """Maintenance window operations. Version: v1"""
118
+
119
+ # Incidents - v3
120
+ INCIDENTS = "/v3/incidents"
121
+ """Incident management operations. Version: v3"""
122
+
123
+ # Reports - v2
124
+ REPORTS = "/v2/reporting/monitor-reports"
125
+ """Uptime and performance reporting. Version: v2"""
126
+
127
+ # Outages - v2
128
+ OUTAGES = "/v2/outages"
129
+ """Auto-detected outage management. Version: v2"""
130
+
131
+ # Status Pages - v2
132
+ STATUSPAGES = "/v2/statuspages"
133
+ """Status page management. Version: v2"""
134
+
135
+
136
+ # Detailed endpoint metadata for programmatic access
137
+ ENDPOINTS: Final[dict[Endpoint, EndpointConfig]] = {
138
+ Endpoint.MONITORS: EndpointConfig(
139
+ version=APIVersion.V1,
140
+ resource="monitors",
141
+ description="Monitor CRUD operations - create, read, update, delete monitors",
142
+ ),
143
+ Endpoint.MAINTENANCE: EndpointConfig(
144
+ version=APIVersion.V1,
145
+ resource="maintenance-windows",
146
+ description="Maintenance window scheduling and management",
147
+ ),
148
+ Endpoint.INCIDENTS: EndpointConfig(
149
+ version=APIVersion.V3,
150
+ resource="incidents",
151
+ description="Incident creation, updates, and resolution",
152
+ ),
153
+ Endpoint.REPORTS: EndpointConfig(
154
+ version=APIVersion.V2,
155
+ resource="reporting/monitor-reports",
156
+ description="Uptime percentages and performance metrics",
157
+ ),
158
+ Endpoint.OUTAGES: EndpointConfig(
159
+ version=APIVersion.V2,
160
+ resource="outages",
161
+ description="Auto-detected outage management (list, ack, resolve, escalate)",
162
+ ),
163
+ Endpoint.STATUSPAGES: EndpointConfig(
164
+ version=APIVersion.V2,
165
+ resource="statuspages",
166
+ description="Status page CRUD, subscribers management",
167
+ ),
168
+ }
169
+
170
+
171
+ # =============================================================================
172
+ # Backward Compatibility - Deprecation Layer
173
+ # =============================================================================
174
+
175
+ # These maintain backward compatibility with existing code.
176
+ # TODO: Deprecate in future version and migrate all usage to Endpoint enum.
177
+
178
+ HYPERPING_API_BASE: Final[str] = API_BASE
179
+ """Deprecated: Use API_BASE instead."""
180
+
181
+ API_PATHS: Final[dict[str, str]] = {
182
+ "monitors": Endpoint.MONITORS.value,
183
+ "maintenance": Endpoint.MAINTENANCE.value,
184
+ "incidents": Endpoint.INCIDENTS.value,
185
+ "reports": Endpoint.REPORTS.value,
186
+ "outages": Endpoint.OUTAGES.value,
187
+ "statuspages": Endpoint.STATUSPAGES.value,
188
+ }
189
+ """Deprecated: Use Endpoint enum instead for type safety."""
190
+
191
+
192
+ # =============================================================================
193
+ # Utility Functions
194
+ # =============================================================================
195
+
196
+
197
+ def get_endpoint_url(endpoint: Endpoint, resource_id: str | None = None) -> str:
198
+ """Build a full URL for an API endpoint.
199
+
200
+ Args:
201
+ endpoint: The API endpoint
202
+ resource_id: Optional resource ID for single-resource operations
203
+
204
+ Returns:
205
+ Full URL string
206
+
207
+ Example:
208
+ >>> get_endpoint_url(Endpoint.MONITORS)
209
+ 'https://api.hyperping.io/v1/monitors'
210
+ >>> get_endpoint_url(Endpoint.MONITORS, "mon_123")
211
+ 'https://api.hyperping.io/v1/monitors/mon_123'
212
+ """
213
+ if resource_id:
214
+ return f"{API_BASE}{endpoint}/{resource_id}"
215
+ return f"{API_BASE}{endpoint}"
216
+
217
+
218
+ def get_version_for_endpoint(endpoint: Endpoint) -> APIVersion:
219
+ """Get the API version used by an endpoint.
220
+
221
+ Args:
222
+ endpoint: The API endpoint
223
+
224
+ Returns:
225
+ The API version enum value
226
+
227
+ Example:
228
+ >>> get_version_for_endpoint(Endpoint.INCIDENTS)
229
+ <APIVersion.V3: 'v3'>
230
+ """
231
+ return ENDPOINTS[endpoint].version