|
|
|
@@ -0,0 +1,99 @@
|
|
|
|
|
"""Typed, dependency-free AS213905 API client."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from typing import Any, Iterable, Mapping, Optional
|
|
|
|
|
from urllib.error import HTTPError, URLError
|
|
|
|
|
from urllib.parse import quote, urlencode
|
|
|
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class APIError(Exception):
|
|
|
|
|
status: int
|
|
|
|
|
code: str
|
|
|
|
|
message: str = ""
|
|
|
|
|
required_scope: Optional[str] = None
|
|
|
|
|
retry_after: Optional[int] = None
|
|
|
|
|
|
|
|
|
|
def __str__(self) -> str:
|
|
|
|
|
detail = f": {self.message}" if self.message else ""
|
|
|
|
|
return f"AS213905 API error {self.status} ({self.code}){detail}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AS213905:
|
|
|
|
|
def __init__(self, api_key: str, *, base_url: str = "https://as213905.com", timeout: float = 30.0) -> None:
|
|
|
|
|
if not api_key.strip():
|
|
|
|
|
raise ValueError("api_key is required")
|
|
|
|
|
self.api_key = api_key
|
|
|
|
|
self.base_url = base_url.rstrip("/")
|
|
|
|
|
self.timeout = timeout
|
|
|
|
|
|
|
|
|
|
def _request(self, method: str, path: str, *, organization_id: Optional[str] = None, body: Any = None) -> Any:
|
|
|
|
|
payload = None if body is None else json.dumps(body).encode()
|
|
|
|
|
headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json", "User-Agent": "as213905-python/1.1.0"}
|
|
|
|
|
if payload is not None:
|
|
|
|
|
headers["Content-Type"] = "application/json"
|
|
|
|
|
if organization_id:
|
|
|
|
|
headers["X-Organization-ID"] = organization_id
|
|
|
|
|
request = Request(self.base_url + path, data=payload, headers=headers, method=method)
|
|
|
|
|
try:
|
|
|
|
|
with urlopen(request, timeout=self.timeout) as response:
|
|
|
|
|
raw = response.read()
|
|
|
|
|
except HTTPError as exc:
|
|
|
|
|
try:
|
|
|
|
|
value = json.loads(exc.read())
|
|
|
|
|
except (ValueError, UnicodeDecodeError):
|
|
|
|
|
value = {}
|
|
|
|
|
retry = value.get("retry_after") or exc.headers.get("Retry-After")
|
|
|
|
|
raise APIError(exc.code, value.get("error", "http_error"), value.get("message", ""), value.get("required_scope"), int(retry) if retry else None) from None
|
|
|
|
|
except URLError as exc:
|
|
|
|
|
raise APIError(0, "network_error", str(exc.reason)) from exc
|
|
|
|
|
return json.loads(raw) if raw else None
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _traffic_query(days: Optional[int], from_date: Optional[str], to_date: Optional[str]) -> str:
|
|
|
|
|
query = {key: value for key, value in (("days", days), ("from", from_date), ("to", to_date)) if value is not None}
|
|
|
|
|
return "?" + urlencode(query) if query else ""
|
|
|
|
|
|
|
|
|
|
def organization(self) -> Mapping[str, Any]:
|
|
|
|
|
return self._request("GET", "/api/v1/organization")["organization"]
|
|
|
|
|
def invoices(self) -> list[Mapping[str, Any]]:
|
|
|
|
|
return self._request("GET", "/api/v1/invoices")["invoices"]
|
|
|
|
|
def services(self) -> list[Mapping[str, Any]]:
|
|
|
|
|
return self._request("GET", "/api/v1/services")["services"]
|
|
|
|
|
def service(self, service_id: str) -> Mapping[str, Any]:
|
|
|
|
|
return self._request("GET", f"/api/v1/services/{quote(service_id, safe='')}" )["service"]
|
|
|
|
|
def service_metrics(self, service_id: str, *, target: Optional[str] = None, range: Optional[str] = None) -> Mapping[str, Any]:
|
|
|
|
|
query = urlencode({k: v for k, v in (("target", target), ("range", range)) if v is not None})
|
|
|
|
|
return self._request("GET", f"/api/v1/services/{quote(service_id, safe='')}/metrics" + ("?" + query if query else ""))
|
|
|
|
|
def power_service(self, service_id: str, action: str) -> Mapping[str, Any]:
|
|
|
|
|
return self._request("POST", f"/api/v1/services/{quote(service_id, safe='')}/power", body={"action": action})
|
|
|
|
|
def traffic(self, *, days: Optional[int] = None, from_date: Optional[str] = None, to_date: Optional[str] = None) -> Mapping[str, Any]:
|
|
|
|
|
return self._request("GET", "/api/v1/traffic" + self._traffic_query(days, from_date, to_date))
|
|
|
|
|
def tunnels(self) -> list[Mapping[str, Any]]:
|
|
|
|
|
return self._request("GET", "/api/v1/tunnels")["tunnels"]
|
|
|
|
|
def create_tunnel(self, peer_endpoint: str, location: str, *, label: Optional[str] = None, announce: bool = False) -> Mapping[str, Any]:
|
|
|
|
|
body = {"peer_endpoint": peer_endpoint, "location": location, "announce": announce}
|
|
|
|
|
if label is not None: body["label"] = label
|
|
|
|
|
return self._request("POST", "/api/v1/tunnels", body=body)["tunnel"]
|
|
|
|
|
def delete_tunnel(self, tunnel_id: str) -> None:
|
|
|
|
|
self._request("DELETE", f"/api/v1/tunnels/{quote(tunnel_id, safe='')}")
|
|
|
|
|
def tunnel_traffic(self, tunnel_id: str, *, days: Optional[int] = None, from_date: Optional[str] = None, to_date: Optional[str] = None) -> Mapping[str, Any]:
|
|
|
|
|
return self._request("GET", f"/api/v1/tunnels/{quote(tunnel_id, safe='')}/traffic" + self._traffic_query(days, from_date, to_date))
|
|
|
|
|
def geofeeds(self, organization_id: str) -> Mapping[str, Any]:
|
|
|
|
|
return self._request("GET", "/api/v1/geofeeds", organization_id=organization_id)
|
|
|
|
|
def create_geofeed(self, organization_id: str, name: str) -> Mapping[str, Any]:
|
|
|
|
|
return self._request("POST", "/api/v1/geofeeds", organization_id=organization_id, body={"name": name})
|
|
|
|
|
def geofeed(self, organization_id: str, geofeed_id: str) -> Mapping[str, Any]:
|
|
|
|
|
return self._request("GET", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}", organization_id=organization_id)["geofeed"]
|
|
|
|
|
def rename_geofeed(self, organization_id: str, geofeed_id: str, name: str) -> None:
|
|
|
|
|
self._request("PATCH", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}", organization_id=organization_id, body={"name": name})
|
|
|
|
|
def delete_geofeed(self, organization_id: str, geofeed_id: str) -> None:
|
|
|
|
|
self._request("DELETE", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}", organization_id=organization_id)
|
|
|
|
|
def replace_geofeed_records(self, organization_id: str, geofeed_id: str, records: Iterable[Mapping[str, Any]]) -> None:
|
|
|
|
|
self._request("PUT", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}/records", organization_id=organization_id, body={"records": list(records)})
|
|
|
|
|
def rotate_geofeed_url(self, organization_id: str, geofeed_id: str) -> Mapping[str, Any]:
|
|
|
|
|
return self._request("POST", f"/api/v1/geofeeds/{quote(geofeed_id, safe='')}/rotate", organization_id=organization_id)
|