feat: app.py — NATS без nkeys при пустых учётных данных, NATS_ENABLED, nats в /healthcheck, префикс /api/v1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 18:36:42 +04:00
parent 56ab8df3fd
commit 545994a525
3 changed files with 181 additions and 51 deletions

View File

@ -1,3 +1,3 @@
from fastapi import APIRouter
router = APIRouter()
router = APIRouter(prefix="/api/v1")

View File

@ -1,7 +1,9 @@
"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода.
Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT), SMB (горячие папки ИС Сканирование),
middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck, раздача собранного SPA.
Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT; пустые NATS_SEED/NATS_JWT — без учётных данных;
подключение при NATS_ENABLED=true независимо от DEBUG), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy
headers, CORS, РАИСА при DEBUG=False), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA.
Недоступность S3 или NATS при старте не роняет приложение: ошибка логируется, healthcheck показывает состояние.
Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result
(docs/context/03-architecture.md), зависимость require(permission).
"""
@ -11,10 +13,11 @@ import os
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from http import HTTPStatus
from typing import Any
import nkeys
import smbclient
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.exceptions import HTTPException, StarletteHTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
@ -26,7 +29,7 @@ from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from src.api.router import router
from src.core.clients import S3Storage
from src.core.config import get_settings
from src.core.config import NATSSettings, get_settings
from src.core.exception_handlers import register_exception_handlers
settings = get_settings()
@ -36,19 +39,31 @@ UP_TIME = datetime.now(UTC)
# --- NATS: аутентификация по nkeys/JWT ---------------------------------------------------------
# Пустой NATS_SEED — подключение без nkeys (разработка, тесты); подключение переедет в core/nats.py (задача 22)
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) if settings.OPENBAO.NATS.NATS_SEED else None
def _build_nats_auth(nats_settings: NATSSettings) -> dict[str, Any]:
"""Параметры аутентификации для ``NATS.connect``.
Пустые NATS_SEED и NATS_JWT — подключение без учётных данных (docker compose, тесты): ``{}``. Заданы оба —
колбэки nkeys/JWT, как в шаблоне. Задан только один — ошибка конфигурации. Подключение переедет в core/nats.py
(задача 22).
"""
seed = nats_settings.NATS_SEED
jwt = nats_settings.NATS_JWT
if not seed and not jwt:
return {}
if not seed or not jwt:
raise RuntimeError("Неполные учётные данные NATS: NATS_SEED и NATS_JWT задаются вместе или не задаются вовсе")
def jwt_cb():
return settings.OPENBAO.NATS.NATS_JWT.encode("utf-8")
keypair = nkeys.from_seed(seed.encode("utf-8"))
def user_jwt_cb() -> bytes:
return jwt.encode("utf-8")
def sig_cb(nonce):
assert kp is not None
if isinstance(nonce, str):
nonce = nonce.encode("utf-8")
return base64.b64encode(kp.sign(nonce))
def signature_cb(nonce: str | bytes) -> bytes:
if isinstance(nonce, str):
nonce = nonce.encode("utf-8")
return base64.b64encode(keypair.sign(nonce))
return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb}
# --- РАИСА: защищённый API (только вне DEBUG) ---------------------------------------------------
@ -81,6 +96,52 @@ def _normalize_root_path(value: str) -> str:
_APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH)
async def _connect_s3() -> S3Storage | None:
"""Клиент S3 с проверкой бакета; при недоступности — ``None`` (приложение всё равно стартует)."""
s3_client = Minio(
settings.OPENBAO.MINIO.MINIO_URL,
access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN,
secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD,
cert_check=False,
)
try:
bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET)
except Exception as exc:
logger.exception(
"Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
await s3_client.close_session()
return None
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists)
return S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
async def _connect_nats() -> NATS | None:
"""Подключение к NATS при NATS_ENABLED=true; при ошибке — ``None`` (healthcheck покажет ``down``)."""
if not settings.NATS_ENABLED:
logger.info("NATS выключен (NATS_ENABLED=false)")
return None
nc = NATS()
try:
await nc.connect(
settings.OPENBAO.NATS.NATS_URL,
max_reconnect_attempts=-1,
reconnect_time_wait=2,
**_build_nats_auth(settings.OPENBAO.NATS),
)
except Exception as exc:
logger.exception(
"Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
return None
logger.info("Подключение к NATS произошло")
return nc
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.nats_subs = []
@ -94,42 +155,11 @@ async def lifespan(app: FastAPI):
except Exception:
logger.exception("Ошибка при регистрации сессии SMB")
s3_client = Minio(
settings.OPENBAO.MINIO.MINIO_URL,
access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN,
secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD,
cert_check=False,
)
try:
bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET)
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists)
except Exception as exc:
logger.exception(
"Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
return
app.state.storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
app.state.storage = await _connect_s3()
app.state.nats = await _connect_nats()
# Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md
nc = NATS()
if not settings.DEBUG:
try:
await nc.connect(
settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb, max_reconnect_attempts=2
)
logger.info("Подключение к NATS произошло")
except Exception as exc:
logger.exception(
"Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
return
app.state.nats = nc
# Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md
try:
logger.info("Приложение запущено")
yield
finally:
@ -137,7 +167,7 @@ async def lifespan(app: FastAPI):
smbclient.reset_connection_cache()
for sub in app.state.nats_subs:
await sub.unsubscribe()
if app.state.nats and not settings.DEBUG:
if app.state.nats:
await app.state.nats.close()
if app.state.storage:
await app.state.storage.client.close_session()
@ -147,8 +177,13 @@ app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH
@app.get("/healthcheck", status_code=200)
async def healthcheck():
return {"uptime": UP_TIME, "version": VERSION}
async def healthcheck(request: Request):
"""Живость приложения. ``nats``: ``off`` — NATS выключен, ``ok`` — подключён, ``down`` — включён, но нет связи."""
nats_status = "off"
if settings.NATS_ENABLED:
nc = getattr(request.app.state, "nats", None)
nats_status = "ok" if nc is not None and nc.is_connected else "down"
return {"uptime": UP_TIME, "version": VERSION, "nats": nats_status}
app.include_router(router)

95
api/tests/test_app.py Normal file
View File

@ -0,0 +1,95 @@
"""Быстрые тесты каркаса приложения: аутентификация NATS, /healthcheck, префикс API (без БД и NATS)."""
import importlib
import os
from http import HTTPStatus
from types import SimpleNamespace
import httpx
import nkeys
import pytest
from src.api.router import router
from src.core.config import NATSSettings
# src/__init__.py реэкспортирует объект app под именем модуля, поэтому модуль берём через importlib
app_module = importlib.import_module("src.app")
def _nats_settings(seed: str = "", jwt: str = "") -> NATSSettings:
return NATSSettings(NATS_URL="nats://localhost:4222", NATS_SEED=seed, NATS_JWT=jwt)
def _valid_seed() -> str:
return nkeys.encode_seed(os.urandom(32), nkeys.PREFIX_BYTE_USER).decode("utf-8")
def test_build_nats_auth_empty_credentials_gives_no_kwargs():
assert app_module._build_nats_auth(_nats_settings()) == {}
@pytest.mark.parametrize(
("seed", "jwt"),
[("SU-only-seed", ""), ("", "only-jwt")],
)
def test_build_nats_auth_partial_credentials_is_config_error(seed: str, jwt: str):
with pytest.raises(RuntimeError, match="NATS_SEED"):
app_module._build_nats_auth(_nats_settings(seed, jwt))
def test_build_nats_auth_valid_credentials_give_callbacks():
seed = _valid_seed()
auth = app_module._build_nats_auth(_nats_settings(seed, "header.payload.signature"))
assert set(auth) == {"user_jwt_cb", "signature_cb"}
assert auth["user_jwt_cb"]() == b"header.payload.signature"
signature = auth["signature_cb"](b"nonce")
assert isinstance(signature, bytes)
assert signature
@pytest.fixture
async def client():
# Без lifespan: app.state.nats не установлен — healthcheck обязан это пережить
transport = httpx.ASGITransport(app=app_module.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
async def test_healthcheck_nats_off_when_disabled(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False)
response = await client.get("/healthcheck")
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["nats"] == "off"
assert {"uptime", "version"} <= set(body)
async def test_healthcheck_nats_down_when_enabled_without_connection(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True)
response = await client.get("/healthcheck")
assert response.status_code == HTTPStatus.OK
assert response.json()["nats"] == "down"
@pytest.mark.parametrize(("connected", "expected"), [(True, "ok"), (False, "down")])
async def test_healthcheck_nats_follows_connection_state(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, connected: bool, expected: str
):
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True)
monkeypatch.setattr(app_module.app.state, "nats", SimpleNamespace(is_connected=connected), raising=False)
response = await client.get("/healthcheck")
assert response.status_code == HTTPStatus.OK
assert response.json()["nats"] == expected
def test_router_prefix():
assert router.prefix == "/api/v1"