"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода. Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT), SMB (горячие папки ИС Сканирование), middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck, раздача собранного SPA. Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result (docs/context/03-architecture.md), зависимость require(permission). """ import base64 import os from contextlib import asynccontextmanager from datetime import datetime, timezone import nkeys import smbclient from fastapi import FastAPI from fastapi.exceptions import HTTPException, StarletteHTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from loguru import logger from miniopy_async import Minio from nats.aio.client import Client as NATS from starlette.middleware.gzip import GZipMiddleware 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.exception_handlers import register_exception_handlers settings = get_settings() VERSION = settings.VERSION UP_TIME = datetime.now(timezone.utc) # --- NATS: аутентификация по nkeys/JWT --------------------------------------------------------- kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) def jwt_cb(): return settings.OPENBAO.NATS.NATS_JWT.encode("utf-8") def sig_cb(nonce): if isinstance(nonce, str): nonce = nonce.encode("utf-8") return base64.b64encode(kp.sign(nonce)) # --- РАИСА: защищённый API (только вне DEBUG) --------------------------------------------------- protected_middleware_available = False if not settings.DEBUG: try: from raisa_fastapi_protected_api import ( # pyright: ignore[reportMissingImports] AuthorizationMiddleware, OpenEndpoint, ProtectedOAuthSettings, ProtectedRolesSettings, ProtectedSettings, SearchType, ) protected_middleware_available = True except ImportError: protected_middleware_available = False def _normalize_root_path(value: str) -> str: if not value: return "" value = value.strip() if value == "/": return "" return "/" + value.strip("/") _APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH) @asynccontextmanager async def lifespan(app: FastAPI): app.state.nats_subs = [] app.state.storage = None app.state.nats = None try: smbclient.register_session( settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD ) 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) 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: logger.info("Приложение закрывается") smbclient.reset_connection_cache() for sub in app.state.nats_subs: await sub.unsubscribe() if app.state.nats and not settings.DEBUG: await app.state.nats.close() if app.state.storage: await app.state.storage.client.close_session() app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) @app.get("/healthcheck", status_code=200) async def healthcheck(): return {"uptime": UP_TIME, "version": VERSION} app.include_router(router) register_exception_handlers(app) app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- Раздача собранного SPA (web/) --------------------------------------------------------------- os.makedirs("web", exist_ok=True) def sep_static(path: str, stat_dir: str) -> str: sep_path = path.split("/") index = sep_path.index(stat_dir) return "/".join(sep_path[index:]) class SPAStaticFiles(StaticFiles): async def get_response(self, path: str, scope): try: return await super().get_response(path, scope) except (HTTPException, StarletteHTTPException) as ex: if ex.status_code == 404: if "images" in path: return await super().get_response(sep_static(path, "images"), scope) if "static" in path: return await super().get_response(sep_static(path, "static"), scope) return await super().get_response("index.html", scope) raise app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")