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