Files
app-autotest/site/api/http_client.py
T

189 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
HTTP-клиент для Nubes API — тонкая обёртка над requests.Session.
HttpClient:
- Добавляет обязательные заголовки:
Authorization: Bearer <token> — аутентификация
User-Agent: Mozilla/5.0 — DDoS-Guard блокирует python-requests по умолчанию
- GET: raise_for_status → .json() — автоматически проверяет HTTP-статус
- POST: проверка r.ok, извлечение Location-заголовка + UUID
- raw_delete: DELETE без авторизации (для CMDB)
Функции автоопределения стенда:
- detect_endpoint(token) — пробует настроенные API стенды по токену
- stand_name(endpoint) — возвращает имя стенда по URL
- create_client(token) — HttpClient + endpoint одним вызовом
Зачем автоопределение: пользователь вводит токен, мы не знаем его стенд.
Пробуем настроенные URL — какой ответит с results != None, тот и рабочий.
"""
import requests
# Источник истины для URL облачных API. Меняй значения здесь и передеплой,
# а не через NUBES_API_ENDPOINT в параметрах сервиса.
API_ENDPOINTS = {
"dev": "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
"test": "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
"prod": "https://lk-api-gateway.ngcloud.ru/api/v1/svc",
}
DEFAULT_STAND = "test"
DEFAULT_ENDPOINT = API_ENDPOINTS[DEFAULT_STAND]
STANDS = list(API_ENDPOINTS.values())
def detect_endpoint(token, endpoints=None):
"""Пробуем токен против заданных API стендов, возвращаем рабочий URL.
Алгоритм:
1. Для каждого стенда создаём HttpClient + делаем GET /instances?pageSize=1.
2. Если results != None — стенд рабочий, токен валиден → возвращаем URL.
3. Если Exception (401/403/таймаут) — пробуем следующий.
4. Ни один не подошёл → None.
Почему results != None а не просто HTTP 200:
API может вернуть 200 с пустым списком (нет инстансов) — это норм.
results=None — признак что ответ не соответствует ожидаемой структуре."""
for ep in endpoints or STANDS:
try:
c = HttpClient(ep, token)
data = c.get("/instances", params={"pageSize": 1, "page": 1})
if data.get("results") is not None:
return ep
except Exception:
continue
return None
def stand_name(endpoint):
"""Определить имя стенда по URL — точное совпадение endpoint.
Возвращает имя стенда или '?' если URL неизвестен."""
for name, url in API_ENDPOINTS.items():
if (endpoint or "").rstrip("/") == url.rstrip("/"):
return name
return "?"
def create_client(token, fallback_endpoint=None):
"""HttpClient с автоопределением стенда.
Сначала detect_endpoint(token) — dev→test→prod.
Если не сработало — fallback_endpoint (из конфига).
Возвращает (HttpClient, endpoint_url) или None."""
ep = detect_endpoint(token) or fallback_endpoint
if not ep:
return None
return HttpClient(ep, token), ep
class HttpClient:
"""HTTP-клиент для Nubes REST API.
Использует requests.Session для:
- Keep-alive (переиспользование TCP+TLS между запросами)
- Единые заголовки для всех запросов
Обязательные заголовки:
- Authorization: Bearer <token> — без этого API вернёт 401
- User-Agent: Mozilla/5.0 — DDoS-Guard блокирует "python-requests/2.x"
Таймауты: GET=10с (лёгкие), POST=30с (создание ресурсов дольше)."""
def __init__(self, endpoint, token):
# Убираем trailing slash (".../svc/" → ".../svc")
# чтобы path добавлялся как "/path", а не "path"
self._endpoint = endpoint.rstrip("/")
# Session переиспользует TCP + TLS handshake между запросами.
# Без Session каждый запрос делал бы новый connect → медленно.
self._session = requests.Session()
self._session.headers.update({
"Authorization": f"Bearer {token}",
"User-Agent": "Mozilla/5.0", # DDoS-Guard: не python-requests
})
def get(self, path, **kwargs):
"""GET-запрос к Nubes API.
Особенности:
- Таймаут 10 секунд по умолчанию.
- raise_for_status() — 4xx/5xx → HTTPError (ловим выше).
- Пустой ответ (нет body) → {} (норма для validate-cfs).
Args:
path: str — путь относительно endpoint (напр. "/instances").
**kwargs — params, timeout, headers и т.д.
Returns:
dict/list — распарсенный JSON, или {} если ответ пустой."""
kwargs.setdefault("timeout", 10)
r = self._session.get(f"{self._endpoint}{path}", **kwargs)
r.raise_for_status()
if not r.text or not r.text.strip():
return {} # пустой ответ — норма для некоторых эндпоинтов
return r.json()
def post(self, path, data=None, **kwargs):
"""POST-запрос к Nubes API.
Особенности:
- Таймаут 30 секунд по умолчанию.
- data → json=... — requests сам ставит Content-Type: application/json.
- Извлекает Location-заголовок → UUID → кладёт в instanceUid/instanceOperationUid.
Это нужно чтобы не полагаться только на тело ответа (которое может быть пустым).
- При HTTP-ошибке: Exception с кодом и телом ответа.
Args:
path: str — путь (напр. "/instances").
data: dict|None — тело запроса (сериализуется в JSON).
Returns:
dict с полями ответа + _status + _location (+ UUID если найден)."""
kwargs.setdefault("timeout", 30)
url = f"{self._endpoint}{path}"
# json=... — requests сам сериализует и ставит Content-Type: application/json
r = self._session.post(url, json=(data if data is not None else {}), **kwargs)
if not r.ok:
raise Exception(f"POST {path}: {r.status_code} {r.reason}: {r.text[:200]}")
result = {"_status": r.status_code}
try:
parsed = r.json()
if isinstance(parsed, dict):
result = parsed
result["_status"] = r.status_code
except Exception:
pass # тело не JSON — ок, UUID извлечём из Location
# Location-заголовок: "./UUID" или "/api/v1/svc/.../UUID"
loc = r.headers.get("Location", "")
if loc:
result["_location"] = loc
# Извлекаем UUID — последний сегмент после split("/")
parts = loc.rstrip("/").split("/")
uid = parts[-1]
# Проверка: не ".", длина >= 32 (UUID = 36 символов с дефисами)
if uid and uid != "." and len(uid) >= 32:
# Кладём UUID в правильное поле в зависимости от эндпоинта
if "/instanceOperations" in path:
result["instanceOperationUid"] = uid
elif "/instances" in path:
result["instanceUid"] = uid
return result
def raw_delete(self, url):
"""DELETE-запрос к произвольному URL — БЕЗ авторизации.
Используется для CMDB API (cmdb-api.deck.nubes.ru) —
жёсткое удаление недосозданных/зависших инстансов.
CMDB не требует Bearer-токена.
Args:
url: str — полный URL (не path, другой хост!)
Returns:
(ok: bool, status_code: int|str)."""
try:
r = requests.delete(url, timeout=10)
return r.ok, r.status_code
except Exception as e:
return False, str(e)