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:
parent
56ab8df3fd
commit
545994a525
@ -1,3 +1,3 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
135
api/src/app.py
135
api/src/app.py
@ -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
95
api/tests/test_app.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user