asockslib 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.
asockslib/client.py ADDED
@@ -0,0 +1,634 @@
1
+ """Async HTTP client for the ASocks REST API v2.
2
+
3
+ Covers all 25 endpoints at https://docs.asocks.com/en/.
4
+
5
+ Features:
6
+ - Automatic retry with configurable exponential back-off on rate-limit
7
+ (HTTP 429), server errors (HTTP 5xx) and transient network failures.
8
+ - Non-idempotent requests (POST) are never retried on network/5xx errors
9
+ so that a create-port call can never be silently duplicated.
10
+ - Typed exceptions for every error HTTP status; network failures are
11
+ wrapped in :class:`APIConnectionError`.
12
+ - Authentication via ``apiKey`` query parameter.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import logging
19
+ from typing import TYPE_CHECKING, Any, cast
20
+
21
+ if TYPE_CHECKING:
22
+ from types import TracebackType
23
+
24
+ import httpx
25
+ from beartype import beartype
26
+ from pydantic import ValidationError
27
+
28
+ from asockslib.exceptions import (
29
+ APIConnectionError,
30
+ ASocksError,
31
+ AuthenticationError,
32
+ InsufficientBalanceError,
33
+ PortNotFoundError,
34
+ RateLimitError,
35
+ )
36
+ from asockslib.models import (
37
+ ASNInfo,
38
+ ASNListResponse,
39
+ BalanceResponse,
40
+ CityInfo,
41
+ CountryInfo,
42
+ CreatePortRequest,
43
+ CreateTemplateRequest,
44
+ PortFilterParams,
45
+ PortInfo,
46
+ PortListResponse,
47
+ StateInfo,
48
+ UpdatePortRequest,
49
+ UpdateTemplateRequest,
50
+ WhitelistAddRequest,
51
+ )
52
+
53
+ logger = logging.getLogger("asockslib")
54
+
55
+ _BASE_URL = "https://api.asocks.com"
56
+ _DEFAULT_TIMEOUT = 30.0
57
+ _DEFAULT_MAX_RETRIES = 5
58
+ _DEFAULT_BACKOFF_BASE = 1.0
59
+ _DEFAULT_BACKOFF_MAX = 30.0
60
+
61
+
62
+ class ASocksClient:
63
+ """Async client for the ASocks Proxy API v2.
64
+
65
+ Use as an async context manager so the HTTP session is
66
+ closed automatically::
67
+
68
+ async with ASocksClient(api_key="key") as client:
69
+ balance = await client.get_balance()
70
+
71
+ Args:
72
+ api_key: ASocks API key.
73
+ base_url: API base URL (default ``https://api.asocks.com``).
74
+ timeout: HTTP request timeout in seconds.
75
+ max_retries: Maximum number of attempts for a retryable request
76
+ (HTTP 429, HTTP 5xx and transient network errors). ``1`` disables
77
+ retrying. Applies per request.
78
+ retry_backoff_base: Base delay in seconds for exponential back-off
79
+ (attempt *n* waits ``base * 2**(n-1)`` seconds, capped at
80
+ *retry_backoff_max*). Set to ``0`` to retry with no delay.
81
+ retry_backoff_max: Upper bound on the back-off delay in seconds.
82
+ """
83
+
84
+ @beartype
85
+ def __init__(
86
+ self,
87
+ api_key: str,
88
+ *,
89
+ base_url: str = _BASE_URL,
90
+ timeout: float = _DEFAULT_TIMEOUT,
91
+ max_retries: int = _DEFAULT_MAX_RETRIES,
92
+ retry_backoff_base: float = _DEFAULT_BACKOFF_BASE,
93
+ retry_backoff_max: float = _DEFAULT_BACKOFF_MAX,
94
+ ) -> None:
95
+ if not api_key:
96
+ raise ValueError(
97
+ "api_key is required. Get one at https://my.asocks.com and pass it "
98
+ "as ASocksClient(api_key=...), or set the ASOCKS_API_KEY env var."
99
+ )
100
+ if max_retries < 1:
101
+ raise ValueError("max_retries must be >= 1")
102
+ self._api_key = api_key
103
+ self._base_url = base_url.rstrip("/")
104
+ self._max_retries = max_retries
105
+ self._retry_backoff_base = max(0.0, retry_backoff_base)
106
+ self._retry_backoff_max = max(0.0, retry_backoff_max)
107
+ self._client = httpx.AsyncClient(
108
+ base_url=self._base_url,
109
+ headers={
110
+ "Content-Type": "application/json",
111
+ "Accept": "application/json",
112
+ },
113
+ timeout=timeout,
114
+ )
115
+
116
+ async def __aenter__(self) -> ASocksClient:
117
+ return self
118
+
119
+ async def __aexit__(
120
+ self,
121
+ exc_type: type[BaseException] | None,
122
+ exc_val: BaseException | None,
123
+ exc_tb: TracebackType | None,
124
+ ) -> None:
125
+ await self.close()
126
+
127
+ async def close(self) -> None:
128
+ """Close the underlying HTTP client."""
129
+ await self._client.aclose()
130
+
131
+ # ── Internal helpers ──────────────────────────────────────────────── #
132
+
133
+ def _handle_error(self, response: httpx.Response) -> None:
134
+ """Map a non-2xx response to a typed exception."""
135
+ status = response.status_code
136
+ try:
137
+ body: dict[str, Any] = response.json()
138
+ except Exception:
139
+ body = {}
140
+ # response.json() can legitimately return a non-dict (e.g. a JSON
141
+ # array) at runtime — the annotation above doesn't enforce that, so
142
+ # this guard is load-bearing even though it looks redundant to pyright.
143
+ if not isinstance(body, dict): # pyright: ignore[reportUnnecessaryIsInstance]
144
+ body = {}
145
+
146
+ message = str(
147
+ body.get("message", "") or body.get("error", "") or response.text or f"HTTP {status}"
148
+ )
149
+
150
+ if status in (401, 403):
151
+ raise AuthenticationError(message, status_code=status)
152
+ if status == 404:
153
+ raise PortNotFoundError(message, status_code=status)
154
+ if status == 402:
155
+ raise InsufficientBalanceError(message, status_code=status)
156
+ if status == 429:
157
+ raise RateLimitError(message, status_code=status)
158
+ raise ASocksError(message, status_code=status)
159
+
160
+ def _backoff_delay(self, attempt: int) -> float:
161
+ """Exponential back-off delay (seconds) for the given 1-based attempt."""
162
+ return min(self._retry_backoff_base * (2 ** (attempt - 1)), self._retry_backoff_max)
163
+
164
+ async def _request(
165
+ self,
166
+ method: str,
167
+ path: str,
168
+ *,
169
+ params: dict[str, Any] | None = None,
170
+ json_body: dict[str, Any] | None = None,
171
+ ) -> Any: # noqa: ANN401 — raw, not-yet-validated JSON response body
172
+ """Send an HTTP request to the API with automatic retry.
173
+
174
+ Retries on HTTP 429, HTTP 5xx and transient network errors with
175
+ exponential back-off. To avoid duplicating side effects, 5xx and
176
+ network errors are **not** retried for non-idempotent (POST)
177
+ requests — only 429 (which means the request was rejected, not
178
+ processed) is retried in that case.
179
+
180
+ Raises:
181
+ APIConnectionError: Network/transport failure after all retries.
182
+ RateLimitError / ASocksError: Mapped from the final HTTP response.
183
+ """
184
+ merged_params: dict[str, Any] = {"apiKey": self._api_key}
185
+ if params:
186
+ merged_params.update(params)
187
+
188
+ # POST is not idempotent: a network timeout or 5xx might mean the
189
+ # server already processed the request, so retrying could create
190
+ # duplicate ports. Only 429 (guaranteed-not-processed) is retried.
191
+ idempotent = method.upper() != "POST"
192
+
193
+ for attempt in range(1, self._max_retries + 1):
194
+ last_attempt = attempt >= self._max_retries
195
+ logger.debug("%s %s params=%s body=%s", method, path, merged_params, json_body)
196
+ try:
197
+ response = await self._client.request(
198
+ method,
199
+ path,
200
+ params=merged_params,
201
+ json=json_body,
202
+ )
203
+ except httpx.TransportError as exc:
204
+ if idempotent and not last_attempt:
205
+ delay = self._backoff_delay(attempt)
206
+ logger.warning(
207
+ "Network error on %s %s (attempt %d/%d): %s; retrying in %.1fs",
208
+ method,
209
+ path,
210
+ attempt,
211
+ self._max_retries,
212
+ exc,
213
+ delay,
214
+ )
215
+ await asyncio.sleep(delay)
216
+ continue
217
+ raise APIConnectionError(
218
+ f"Network error contacting the ASocks API "
219
+ f"({method} {path}) after {attempt} attempt(s): {exc}"
220
+ ) from exc
221
+
222
+ if response.is_success:
223
+ return response.json()
224
+
225
+ status = response.status_code
226
+ retryable = status == 429 or (500 <= status < 600 and idempotent)
227
+ if retryable and not last_attempt:
228
+ delay = self._backoff_delay(attempt)
229
+ logger.warning(
230
+ "HTTP %d on %s %s (attempt %d/%d); retrying in %.1fs",
231
+ status,
232
+ method,
233
+ path,
234
+ attempt,
235
+ self._max_retries,
236
+ delay,
237
+ )
238
+ await asyncio.sleep(delay)
239
+ continue
240
+ self._handle_error(response) # raises a typed ASocksError
241
+
242
+ # Unreachable: the loop either returns or raises on the last attempt.
243
+ raise ASocksError("Request failed after exhausting all retries")
244
+
245
+ @staticmethod
246
+ def _as_dict(data: Any) -> dict[str, Any]: # noqa: ANN401 — raw, pre-validation JSON
247
+ """Narrow a raw JSON response to a dict; ``{}`` if it wasn't one.
248
+
249
+ ``_request()`` returns whatever the API sent (dict, list, ...); this
250
+ gives every call site a properly-typed ``dict[str, Any]`` to call
251
+ ``.get()`` on instead of re-deriving an ``isinstance`` guard each time.
252
+ """
253
+ return cast("dict[str, Any]", data) if isinstance(data, dict) else {}
254
+
255
+ @staticmethod
256
+ def _flatten_port(item: Any) -> Any: # noqa: ANN401 — raw, pre-validation JSON
257
+ """Flatten nested ``{proxy: ..., info: ...}`` to a flat dict.
258
+
259
+ The API sometimes returns port data in a nested structure.
260
+ This normalises it for ``PortInfo.model_validate()``.
261
+ """
262
+ if not isinstance(item, dict):
263
+ return item
264
+ item = cast("dict[str, Any]", item)
265
+ if "proxy" in item and "info" in item:
266
+ proxy: dict[str, Any] = item["proxy"]
267
+ info_block: dict[str, Any] = item["info"]
268
+ geo: dict[str, Any] = info_block.get("geo", {}) or {}
269
+ traffic: dict[str, Any] = info_block.get("traffic", {}) or {}
270
+ auth: dict[str, Any] = proxy.get("auth") or {}
271
+ return {
272
+ "id": info_block.get("id"),
273
+ "host": proxy.get("host", ""),
274
+ "port": proxy.get("port", 0),
275
+ "login": auth.get("login", ""),
276
+ "password": auth.get("password", ""),
277
+ "protocol": proxy.get("protocol", "socks5"),
278
+ "name": info_block.get("name", ""),
279
+ "status": info_block.get("status", 0),
280
+ "proxy_type_id": info_block.get("proxy_type_id"),
281
+ "created_at": info_block.get("created_at"),
282
+ "expires_at": info_block.get("expires_at"),
283
+ "country": geo.get("country_name", ""),
284
+ "country_code": geo.get("country_code", ""),
285
+ "state": geo.get("state_name", ""),
286
+ "city": geo.get("city_name", ""),
287
+ "asn": geo.get("asn"),
288
+ "external_ip": info_block.get("external_ip", ""),
289
+ "traffic_used": traffic.get("spent"),
290
+ "traffic_limit": traffic.get("limit"),
291
+ "ttl": info_block.get("ttl"),
292
+ "type_id": info_block.get("type_id"),
293
+ "server_port_type_id": info_block.get("server_port_type_id"),
294
+ }
295
+ return item
296
+
297
+ # ── User ──────────────────────────────────────────────────────────── #
298
+
299
+ @beartype
300
+ async def get_balance(self) -> BalanceResponse:
301
+ """Get account balance. ``GET /v2/user/balance``"""
302
+ data = await self._request("GET", "/v2/user/balance")
303
+ return BalanceResponse.model_validate(data)
304
+
305
+ # ── Directory (geo) ───────────────────────────────────────────────── #
306
+
307
+ @beartype
308
+ async def get_countries(self) -> list[CountryInfo]:
309
+ """List available countries. ``GET /v2/dir/countries``"""
310
+ data = await self._request("GET", "/v2/dir/countries")
311
+ raw = self._as_dict(data).get("countries", [])
312
+ return [CountryInfo.model_validate(item) for item in raw]
313
+
314
+ @beartype
315
+ async def get_states(self, country_id: int | None = None) -> list[StateInfo]:
316
+ """List states/regions. ``GET /v2/dir/states``
317
+
318
+ Args:
319
+ country_id: Filter by country ID.
320
+ """
321
+ params: dict[str, Any] = {}
322
+ if country_id is not None:
323
+ params["countryId"] = country_id
324
+ data = await self._request("GET", "/v2/dir/states", params=params)
325
+ raw = self._as_dict(data).get("states", [])
326
+ return [StateInfo.model_validate(item) for item in raw]
327
+
328
+ @beartype
329
+ async def get_cities(
330
+ self,
331
+ country_id: int | None = None,
332
+ state_id: int | None = None,
333
+ ) -> list[CityInfo]:
334
+ """List cities. ``GET /v2/dir/cities``
335
+
336
+ Args:
337
+ country_id: Filter by country ID.
338
+ state_id: Filter by state ID.
339
+ """
340
+ params: dict[str, Any] = {}
341
+ if country_id is not None:
342
+ params["countryId"] = country_id
343
+ if state_id is not None:
344
+ params["stateId"] = state_id
345
+ data = await self._request("GET", "/v2/dir/cities", params=params)
346
+ raw = self._as_dict(data).get("cities", [])
347
+ return [CityInfo.model_validate(item) for item in raw]
348
+
349
+ @beartype
350
+ async def get_asns(
351
+ self,
352
+ country_id: int | None = None,
353
+ state_id: int | None = None,
354
+ city_id: int | None = None,
355
+ page: int | None = None,
356
+ ) -> ASNListResponse:
357
+ """List ASN entries. ``GET /v2/dir/asns``
358
+
359
+ Args:
360
+ country_id: Filter by country.
361
+ state_id: Filter by state.
362
+ city_id: Filter by city.
363
+ page: Pagination page number.
364
+ """
365
+ params: dict[str, Any] = {}
366
+ if country_id is not None:
367
+ params["countryId"] = country_id
368
+ if state_id is not None:
369
+ params["stateId"] = state_id
370
+ if city_id is not None:
371
+ params["cityId"] = city_id
372
+ if page is not None:
373
+ params["page"] = page
374
+ result = await self._request("GET", "/v2/dir/asns", params=params)
375
+ raw_asns = self._as_dict(result).get("asns", result)
376
+ if isinstance(raw_asns, list):
377
+ raw_asns = cast("list[Any]", raw_asns)
378
+ return ASNListResponse(data=[ASNInfo.model_validate(a) for a in raw_asns])
379
+ return ASNListResponse.model_validate(raw_asns)
380
+
381
+ # ── Plan ──────────────────────────────────────────────────────────── #
382
+
383
+ @beartype
384
+ async def get_plan_info(self, show_proxies: str = "") -> dict[str, Any]:
385
+ """Get subscription plan info. ``GET /v2/plan/info``
386
+
387
+ Args:
388
+ show_proxies: Optional proxy type filter
389
+ (``"all"``, ``"mobile"``, ``"residential"``, ``"corporate"``).
390
+ """
391
+ params: dict[str, Any] = {}
392
+ if show_proxies:
393
+ params["showProxies"] = show_proxies
394
+ result: dict[str, Any] = await self._request("GET", "/v2/plan/info", params=params)
395
+ return result
396
+
397
+ # ── Proxy — Search ────────────────────────────────────────────────── #
398
+
399
+ @beartype
400
+ async def search_proxies(
401
+ self,
402
+ country: str = "",
403
+ limit: int = 1,
404
+ types: list[str] | None = None,
405
+ ) -> list[str]:
406
+ """Search available proxies. ``GET /v2/proxy/search``
407
+
408
+ Returns:
409
+ List of ``"IP:port"`` strings.
410
+ """
411
+ params: dict[str, Any] = {"limit": limit}
412
+ if country:
413
+ params["country"] = country
414
+ if types:
415
+ params["types"] = ",".join(types)
416
+ data = await self._request("GET", "/v2/proxy/search", params=params)
417
+
418
+ proxies: list[str] = []
419
+ if isinstance(data, dict):
420
+ for key, value in cast("dict[str, Any]", data).items():
421
+ if key == "success":
422
+ continue
423
+ if isinstance(value, str):
424
+ proxies.append(value)
425
+ elif isinstance(data, list):
426
+ for item in cast("list[Any]", data):
427
+ if isinstance(item, str):
428
+ proxies.append(item)
429
+ return proxies
430
+
431
+ # ── Proxy — CRUD ──────────────────────────────────────────────────── #
432
+
433
+ @beartype
434
+ async def list_ports(
435
+ self,
436
+ filters: PortFilterParams | None = None,
437
+ ) -> PortListResponse:
438
+ """List proxy ports. ``GET /v2/proxy/ports``
439
+
440
+ Args:
441
+ filters: Optional query filters.
442
+ """
443
+ params: dict[str, Any] = {}
444
+ if filters:
445
+ params = {k: v for k, v in filters.model_dump().items() if v is not None}
446
+ data = await self._request("GET", "/v2/proxy/ports", params=params)
447
+
448
+ if isinstance(data, dict):
449
+ data = cast("dict[str, Any]", data)
450
+ msg = data.get("message", data)
451
+ if isinstance(msg, dict):
452
+ msg = cast("dict[str, Any]", msg)
453
+ raw_items = msg.get("data", [])
454
+ if isinstance(raw_items, list):
455
+ raw_items = cast("list[Any]", raw_items)
456
+ flattened = [self._flatten_port(item) for item in raw_items]
457
+ msg["data"] = flattened
458
+ data["message"] = msg
459
+ elif isinstance(msg, list):
460
+ data["message"] = [self._flatten_port(item) for item in cast("list[Any]", msg)]
461
+
462
+ return PortListResponse.model_validate(data)
463
+
464
+ @beartype
465
+ async def get_port(self, port_id: int) -> PortInfo:
466
+ """Get detailed port info. ``GET /v2/proxy/port-info``
467
+
468
+ Raises:
469
+ PortNotFoundError: Port does not exist.
470
+ """
471
+ data = await self._request("GET", "/v2/proxy/port-info", params={"id": port_id})
472
+ msg = self._as_dict(data).get("message", data)
473
+ flat = self._flatten_port(msg)
474
+ return PortInfo.model_validate(flat)
475
+
476
+ @beartype
477
+ async def create_ports(self, request: CreatePortRequest) -> list[PortInfo]:
478
+ """Create proxy ports. ``POST /v2/proxy/create-port``
479
+
480
+ Raises:
481
+ InsufficientBalanceError: Not enough funds.
482
+ """
483
+ body = {k: v for k, v in request.model_dump().items() if v not in (None, "")}
484
+ data = await self._request("POST", "/v2/proxy/create-port", json_body=body)
485
+ items: Any = (
486
+ cast("dict[str, Any]", data).get("data", []) if isinstance(data, dict) else data
487
+ )
488
+ if not isinstance(items, list):
489
+ items = [items]
490
+ result: list[PortInfo] = []
491
+ for item in cast("list[Any]", items):
492
+ try:
493
+ result.append(PortInfo.model_validate(item))
494
+ except ValidationError as exc:
495
+ logger.warning("Skipping invalid port data: %s", exc)
496
+ return result
497
+
498
+ @beartype
499
+ async def delete_port(self, port_id: int) -> bool:
500
+ """Delete a proxy port. ``DELETE /v2/proxy/delete-port``"""
501
+ data = await self._request("DELETE", "/v2/proxy/delete-port", params={"id": port_id})
502
+ return bool(self._as_dict(data).get("success", False))
503
+
504
+ @beartype
505
+ async def archive_port(self, port_id: int) -> bool:
506
+ """Archive a proxy port. ``PATCH /v2/proxy/archive-port``"""
507
+ data = await self._request("PATCH", "/v2/proxy/archive-port", params={"id": port_id})
508
+ return bool(self._as_dict(data).get("success", False))
509
+
510
+ @beartype
511
+ async def unarchive_port(self, port_id: int) -> bool:
512
+ """Restore a port from the archive. ``PATCH /v2/proxy/unarchive``"""
513
+ data = await self._request("PATCH", "/v2/proxy/unarchive", params={"id": port_id})
514
+ return bool(self._as_dict(data).get("success", False))
515
+
516
+ @beartype
517
+ async def refresh_ip(self, port_id: int) -> bool:
518
+ """Refresh the external IP of a port. ``GET /v2/proxy/refresh/{portId}``"""
519
+ data = await self._request("GET", f"/v2/proxy/refresh/{port_id}")
520
+ return bool(self._as_dict(data).get("success", False))
521
+
522
+ @beartype
523
+ async def change_port_name(self, port_id: int, name: str) -> bool:
524
+ """Rename a proxy port. ``PATCH /v2/proxy/change-name``"""
525
+ data = await self._request(
526
+ "PATCH",
527
+ "/v2/proxy/change-name",
528
+ params={"id": port_id},
529
+ json_body={"name": name},
530
+ )
531
+ return bool(self._as_dict(data).get("success", False))
532
+
533
+ @beartype
534
+ async def get_total_spent_traffic(self) -> dict[str, Any]:
535
+ """Get total spent traffic. ``GET /v2/proxy/total-spent-traffic``"""
536
+ result: dict[str, Any] = await self._request("GET", "/v2/proxy/total-spent-traffic")
537
+ return result
538
+
539
+ @beartype
540
+ async def change_credentials(self) -> bool:
541
+ """Regenerate credentials for all ports. ``GET /v2/proxy/change-credentials``"""
542
+ data = await self._request("GET", "/v2/proxy/change-credentials")
543
+ return bool(self._as_dict(data).get("success", False))
544
+
545
+ @beartype
546
+ async def update_port(self, port_id: int, request: UpdatePortRequest) -> dict[str, Any]:
547
+ """Update port parameters. ``PATCH /v2/proxy/update-port/{id}``"""
548
+ body = {k: v for k, v in request.model_dump().items() if v is not None}
549
+ result: dict[str, Any] = await self._request(
550
+ "PATCH",
551
+ f"/v2/proxy/update-port/{port_id}",
552
+ json_body=body,
553
+ )
554
+ return result
555
+
556
+ @beartype
557
+ async def update_port_credentials(self, port_id: int, password: str) -> dict[str, Any]:
558
+ """Update credentials for a single port. ``PUT /v2/proxy/{id}/update-credentials``"""
559
+ result: dict[str, Any] = await self._request(
560
+ "PUT",
561
+ f"/v2/proxy/{port_id}/update-credentials",
562
+ json_body={"password": password},
563
+ )
564
+ return result
565
+
566
+ # ── Templates ─────────────────────────────────────────────────────── #
567
+
568
+ @beartype
569
+ async def list_templates(self, page: int = 1) -> dict[str, Any]:
570
+ """List proxy templates. ``GET /v2/proxy-template``"""
571
+ result: dict[str, Any] = await self._request(
572
+ "GET",
573
+ "/v2/proxy-template",
574
+ params={"page": page},
575
+ )
576
+ return result
577
+
578
+ @beartype
579
+ async def create_template(self, request: CreateTemplateRequest) -> dict[str, Any]:
580
+ """Create a proxy template. ``POST /v2/proxy-template/create-template``"""
581
+ result: dict[str, Any] = await self._request(
582
+ "POST",
583
+ "/v2/proxy-template/create-template",
584
+ json_body=request.model_dump(),
585
+ )
586
+ return result
587
+
588
+ @beartype
589
+ async def update_template(
590
+ self,
591
+ template_id: int,
592
+ request: UpdateTemplateRequest,
593
+ ) -> dict[str, Any]:
594
+ """Update a proxy template. ``PATCH /v2/proxy-template/update-template``"""
595
+ body = {k: v for k, v in request.model_dump().items() if v is not None}
596
+ result: dict[str, Any] = await self._request(
597
+ "PATCH",
598
+ "/v2/proxy-template/update-template",
599
+ params={"id": template_id},
600
+ json_body=body,
601
+ )
602
+ return result
603
+
604
+ @beartype
605
+ async def delete_template(self, template_id: int) -> bool:
606
+ """Delete a proxy template. ``DELETE /v2/proxy-template/delete-template``"""
607
+ data = await self._request(
608
+ "DELETE",
609
+ "/v2/proxy-template/delete-template",
610
+ params={"id": template_id},
611
+ )
612
+ return bool(self._as_dict(data).get("success", False))
613
+
614
+ # ── Whitelist ─────────────────────────────────────────────────────── #
615
+
616
+ @beartype
617
+ async def add_whitelist_ip(self, request: WhitelistAddRequest) -> dict[str, Any]:
618
+ """Add an IP to the whitelist. ``POST /v2/whitelist/add``"""
619
+ result: dict[str, Any] = await self._request(
620
+ "POST",
621
+ "/v2/whitelist/add",
622
+ json_body=request.model_dump(),
623
+ )
624
+ return result
625
+
626
+ @beartype
627
+ async def delete_whitelist_ip(self, ip: str) -> bool:
628
+ """Remove an IP from the whitelist. ``DELETE /v2/whitelist/delete``"""
629
+ data = await self._request(
630
+ "DELETE",
631
+ "/v2/whitelist/delete",
632
+ params={"ip": ip},
633
+ )
634
+ return bool(self._as_dict(data).get("success", False))