Рефакторинг API-слоя, операций и БД

This commit is contained in:
2026-07-31 17:13:54 +04:00
parent 82a3be2067
commit 0783319fd1
15 changed files with 986 additions and 257 deletions
+96 -49
View File
@@ -1,20 +1,27 @@
"""
HTTP-клиент для Nubes API + автоопределение стенда.
HTTP-клиент для Nubes API — тонкая обёртка над requests.Session.
Класс HttpClient — тонкая обёртка над requests.Session:
- Добавляет заголовки: Authorization Bearer, User-Agent (DDoS-Guard)
- GET: raise_for_status → .json()
- POST: проверка r.ok, извлечение Location-заголовка
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) — пробует dev→test стенды, возвращает URL
- stand_name(endpoint) — "dev" / "test" по URL
- create_client(token, fallback) — HttpClient + endpoint (одним вызовом)
Функции автоопределения стенда:
- detect_endpoint(token) — пробует dev→test стенды по токену
- stand_name(endpoint) "dev" / "test" по URL
- create_client(token) — HttpClient + endpoint одним вызовом
Зачем автоопределение: пользователь вводит токен, мы не знаем dev это или test.
Пробуем оба стенда — какой ответит с results != None, тот и рабочий.
"""
import requests
# Список стендов для автоопределения. Порядок важен: dev первый (быстрее).
# Список стендов для автоопределения.
# Порядок ВАЖЕН: dev первый — он быстрее (меньше нагрузка), test — резервный.
STANDS = [
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
@@ -23,10 +30,16 @@ STANDS = [
def detect_endpoint(token):
"""Пробуем токен против dev и test стендов, возвращаем рабочий URL.
Делает GET /instances?pageSize=1 на каждый стенд.
Если results != None — стенд рабочий, возвращаем его URL.
Если ни один не подошёл — возвращаем None."""
Алгоритм:
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 STANDS:
try:
c = HttpClient(ep, token)
@@ -39,10 +52,10 @@ def detect_endpoint(token):
def stand_name(endpoint):
"""dev/test по URL стенда.
"""Определить имя стенда по URL.
Ищет подстроку 'dev' или 'test' в URL.
Если не найдено — возвращает '?'."""
Если не найдено — '?' (неизвестный стенд)."""
for name in ("dev", "test"):
if name in (endpoint or ""):
return name
@@ -50,10 +63,11 @@ def stand_name(endpoint):
def create_client(token, fallback_endpoint=None):
"""HttpClient с автоопределением стенда по токену.
Сначала detect_endpoint(token), если не сработало — fallback_endpoint.
Возвращает кортеж (HttpClient, endpoint_url) или None если стенд не определён."""
"""HttpClient с автоопределением стенда.
Сначала detect_endpoint(token) — dev→test.
Если не сработало — fallback_endpoint (из конфига).
Возвращает (HttpClient, endpoint_url) или None."""
ep = detect_endpoint(token) or fallback_endpoint
if not ep:
return None
@@ -62,43 +76,66 @@ def create_client(token, fallback_endpoint=None):
class HttpClient:
"""HTTP-клиент для Nubes REST API.
Использует requests.Session для keep-alive соединений.
Добавляет обязательные заголовки:
- Authorization: Bearer <token> (аутентификация)
- User-Agent: Mozilla/5.0 (DDoS-Guard требует НЕ python-requests)"""
Использует 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 чтобы потом добавлять "/path"
# Убираем trailing slash (".../svc/" → ".../svc")
# чтобы path добавлялся как "/path", а не "path"
self._endpoint = endpoint.rstrip("/")
# Session переиспользует TCP-соединения между запросами
# Session переиспользует TCP + TLS handshake между запросами.
# Без Session каждый запрос делал бы новый connect → медленно.
self._session = requests.Session()
self._session.headers.update({
"Authorization": f"Bearer {token}",
"User-Agent": "Mozilla/5.0",
"User-Agent": "Mozilla/5.0", # DDoS-Guard: не python-requests
})
def get(self, path, **kwargs):
"""GET-запрос. Таймаут по умолчанию 10 секунд.
Вызывает raise_for_status() — при 4xx/5xx выбрасывает HTTPError.
Возвращает распарсенный JSON (dict/list)."""
"""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 {} # пустой ответ (напр. validate-cfs успех)
return {} # пустой ответ — норма для некоторых эндпоинтов
return r.json()
def post(self, path, data=None, **kwargs):
"""POST-запрос. Таймаут по умолчанию 30 секунд.
Отправляет data как JSON (json=...).
При HTTP-ошибке выбрасывает Exception с кодом и телом ответа.
Возвращает dict:
- Распарсенный JSON (если ответ — валидный JSON-объект)
- + ключ "_location" со значением заголовка Location (если есть)
Location нужен для получения UID созданного ресурса (instanceUid, opUid)."""
"""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
@@ -112,15 +149,17 @@ class HttpClient:
result = parsed
result["_status"] = r.status_code
except Exception:
pass # тело не JSON или не dict — ок, Location всё равно извлечём
pass # тело не JSON — ок, UUID извлечём из Location
# Location-заголовок: "./UUID" или "/api/v1/svc/.../UUID"
loc = r.headers.get("Location", "")
if loc:
# Location бывает вида "./UUID" или "/api/v1/svc/instanceOperations/UUID"
result["_location"] = loc
# Извлечь UUID и положить в правильное поле ответа
# Извлекаем 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:
@@ -128,9 +167,17 @@ class HttpClient:
return result
def raw_delete(self, url):
"""DELETE-запрос к произвольному URL (CMDB API).
Использует отдельную сессию БЕЗ auth-заголовков.
Возвращает кортеж (ok: bool, status_code: int)."""
"""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