"""Точка входа FastAPI сервиса scan-gateway (адаптер ИС Сканирование). Без БД и без фронта. - `create_app(settings)` строит горячую папку (`create_hotfolder`, без обращения к ФС/шаре) и `HealthProvider`; `GET /healthcheck` отдаёт `uptime`, `version`, `nats` (`ok`/`down`/`disabled`), `hotfolder` (`ok`/`down`). - lifespan: регистрация SMB-сессии (только `SCAN_HOTFOLDER_MODE=smb`), S3 (`S3Storage`), NATS (`NatsGateway`), воркер (`Worker` с `NullNormalizer`). Подключение к NATS блокирует до доступности сервера, поэтому идёт фоновой задачей: HTTP отвечает сразу (`nats = down`), подписка `recognition.image.request` появляется после подключения. Сбой этой задачи (например, `NatsConfigError`) — лог error с трассировкой и `nats = down`, HTTP-сервер жив. Остановка: отмена подключения, остановка воркера (задания в работе отменяются), закрытие NATS и S3. - Объекты lifespan доступны в `app.state`: `worker`, `nats`, `storage`, `health` (тесты, задачи 13–14). - `NATS_ENABLED=false` — подключения нет (`nats = disabled`), воркер запросов не получает (dev без NATS). """ import asyncio from contextlib import asynccontextmanager, suppress from datetime import UTC, datetime import smbclient from fastapi import FastAPI from loguru import logger 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 Settings, get_settings from src.core.exception_handlers import register_exception_handlers from src.core.health import HealthProvider from src.core.nats import NatsGateway from src.core.worker import Worker from src.gateway import create_hotfolder from src.normalizer import NullNormalizer async def run_nats(gateway: NatsGateway, worker: Worker) -> None: """Фоновая задача: подключение к NATS (ждёт сервер сколь угодно долго) и подписка воркера на запросы. Ошибка не роняет приложение: лог error с трассировкой; если соединение установлено, а подписка не удалась, соединение закрывается, чтобы `/healthcheck.nats = down` честно показывал, что запросы не принимаются. """ try: if not await gateway.connect(): return except asyncio.CancelledError: raise except Exception: logger.exception("NATS: подключение не выполнено, запросы не принимаются (nats=down) до перезапуска") return try: await gateway.subscribe_requests(worker.on_request) except asyncio.CancelledError: raise except Exception: logger.exception("NATS: подписка на запросы не оформлена, соединение закрывается (nats=down)") await gateway.close() def create_app(settings: Settings | None = None) -> FastAPI: settings = settings or get_settings() version = settings.VERSION up_time = datetime.now(UTC) hotfolder = create_hotfolder(settings) health = HealthProvider(nats_enabled=settings.NATS_ENABLED, hotfolder=hotfolder) @asynccontextmanager async def lifespan(app: FastAPI): smb_session = False if settings.SCAN_HOTFOLDER_MODE == "smb": smb = settings.OPENBAO.SMB try: smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD) smb_session = True except Exception: # учётные данные передаются в каждый вызов smbclient (SmbHotFolder), сессия восстановится при # доступной шаре; пока шара недоступна, /healthcheck.hotfolder = down logger.exception("Ошибка при регистрации сессии SMB") storage = S3Storage.from_settings(settings.OPENBAO.MINIO) gateway = NatsGateway(settings) worker = Worker(settings, storage=storage, publisher=gateway, hotfolder=hotfolder, normalizer=NullNormalizer()) health.nats = gateway app.state.storage = storage app.state.nats = gateway app.state.worker = worker worker.start() nats_task = asyncio.create_task(run_nats(gateway, worker), name="nats-connect") try: logger.info("Приложение запущено") yield finally: logger.info("Приложение закрывается") nats_task.cancel() with suppress(asyncio.CancelledError): await nats_task await worker.stop() await gateway.close() await storage.close() if smb_session: smbclient.reset_connection_cache() app = FastAPI(title="scan-gateway", version=version, root_path=settings.ROOT_PATH, lifespan=lifespan) app.state.health = health @app.get("/healthcheck", status_code=200) async def healthcheck(): return {"uptime": up_time, "version": version, **await health.snapshot()} app.include_router(router) register_exception_handlers(app) app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) return app app = create_app()