arm-core/api/src/app.py
Andrey Epifancev c9d60907d3 Повторная публикация обновляет request_subject; тексты без умолчаний как фактических subject'ов (I-1, M-1, M-3, M-5, #106)
I-1: ветка повторной публикации в _process_one записывает request_subject
(как первая публикация); тест test_republish_updates_request_subject.
M-1: примеры /healthcheck в README (ядро: три subject'а; tabular-parser: два).
M-3: docstring'и и логи не выдают умолчание за фактический subject.
M-5: CLAUDE.md — subject'ы задаются OPENBAO__NATS__NATS_*_SUBJECT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:28:07 +04:00

333 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода.
Что здесь есть: настройки, подключение S3 (MinIO; `MINIO_SECURE` — https или http), NATS (`src/core/nats.py`: core
NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка на subject ответов
(по умолчанию `files.tabular_parsed`) в queue group `arm-core`), SMB (горячие папки ИС Сканирование;
необязателен — при пустом `SMB_HOST` не подключается),
middleware (gzip, proxy headers, CORS по `cors_options`; при DEBUG=False пакет РАИСА лишь условно импортируется,
middleware не подключается), healthcheck (uptime, version, nats: ok|down|off, subjects), раздача собранного
SPA. Документация API (`/docs`, `/redoc`, `/openapi.json`) — только при DEBUG (OQ-66). Приложение собирает
`create_app(settings)`.
Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает
состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор,
вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) и, если S3 подключено, цикл маппинга результатов
(`src/services/result_mapping.py`, Срез 2c D-1) — и останавливает их до закрытия NATS. Сообщения
subject ответов (по умолчанию `files.tabular_parsed`; ack и результаты сервисов) обрабатывает inbox
`handle_message` того же модуля с фабрикой сессий приложения (inbox меняет задание и статус файла; маппинг
результата в доменные таблицы — отдельный цикл, Срез 2c D-1).
Остановка: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД; каждый шаг независим — ошибка одного
логируется и не отменяет остальные. Логгер настраивает `setup_logger` при импорте модуля: без значений локальных
переменных в трейсбеках (#63).
"""
import asyncio
import os
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from functools import partial
from http import HTTPStatus
from typing import Any
import smbclient
from fastapi import FastAPI, Request
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 starlette.middleware.gzip import GZipMiddleware
from starlette.routing import Match, Mount, Route
from starlette.types import Scope
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from src.api.router import API_PREFIX, build_api_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.logger import setup_logger
from src.core.nats import MessageHandler, NatsConnection, used_subjects
from src.db.db import async_session_maker, engine, session_transaction
from src.services.recognition_exchange import ExchangeTasks, handle_message
from src.services.session_audit import SessionAudit
settings = get_settings()
VERSION = settings.VERSION
UP_TIME = datetime.now(UTC)
# Свой обработчик loguru вместо стандартного: diagnose=False — значения локальных переменных не попадают в
# трейсбеки (#63)
setup_logger(level="DEBUG" if settings.DEBUG else "INFO")
# --- РАИСА: защищённый API (только вне DEBUG) ---------------------------------------------------
protected_middleware_available = False
if not settings.DEBUG:
try:
from raisa_fastapi_protected_api import ( # noqa: F401 # 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("/")
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,
secure=settings.OPENBAO.MINIO.MINIO_SECURE,
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)
def _result_handler() -> MessageHandler:
"""Inbox subject ответов (по умолчанию `files.tabular_parsed`): `handle_message` с фабрикой сессий приложения
(маппинг результата — отдельный цикл, Срез 2c design D-1)."""
return partial(handle_message, session_factory=async_session_maker)
async def _subscribe_results(nats: NatsConnection) -> None:
"""Подписки после первого подключения к NATS: subject ответов (по умолчанию `files.tabular_parsed`) в queue
group `arm-core`."""
await nats.subscribe_results(_result_handler())
def _create_nats() -> NatsConnection | None:
"""Соединение NATS при NATS_ENABLED=true; при false — ``None`` (healthcheck покажет ``off``)."""
if not settings.NATS_ENABLED:
logger.info("NATS выключен (NATS_ENABLED=false)")
return None
return NatsConnection(settings.OPENBAO.NATS, on_connected=_subscribe_results)
def _start_exchange(nats: NatsConnection, storage: S3Storage | None) -> ExchangeTasks:
"""Фоновые задачи обмена: публикатор outbox, вахтёр сроков и цикл маппинга результатов (последний — только при
подключённом S3); все — только вместе с NATS, т.е. при NATS_ENABLED=true."""
return ExchangeTasks.start(async_session_maker, nats, settings, storage)
def _smb_configured() -> bool:
return bool(settings.OPENBAO.SMB.SMB_HOST)
async def _register_smb() -> None:
"""Сессия SMB (шаблон) — только если задан `SMB_HOST`; вызов блокирующий, поэтому в отдельном потоке."""
smb = settings.OPENBAO.SMB
if not _smb_configured():
logger.debug("SMB не настроен (OPENBAO__SMB__SMB_HOST пуст): сессия SMB не регистрируется")
return
try:
await asyncio.to_thread(smbclient.register_session, smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD)
except Exception:
logger.exception("Ошибка при регистрации сессии SMB")
async def _reset_smb() -> None:
"""Закрывает кэш соединений SMB (блокирующий вызов — в отдельном потоке); без SMB ничего не делает."""
if _smb_configured():
await asyncio.to_thread(smbclient.reset_connection_cache)
async def _cleanup_step(name: str, action: Callable[[], Awaitable[object]]) -> None:
"""Один шаг остановки: исключение логируется и не мешает следующим шагам."""
try:
await action()
except Exception:
logger.exception("Ошибка при остановке приложения. step={step}", step=name)
async def _shutdown(app: FastAPI) -> None:
"""Остановка по шагам: задачи обмена (до NATS: публикатор не пишет в закрытое соединение) → NATS → S3 → SMB →
пул соединений БД (последним: задачи обмена и inbox к этому моменту остановлены)."""
exchange, nats, storage = app.state.exchange, app.state.nats, app.state.storage
if exchange is not None:
await _cleanup_step("exchange", exchange.stop)
if nats is not None:
await _cleanup_step("nats", nats.stop)
if storage:
await _cleanup_step("s3", storage.client.close_session)
await _cleanup_step("smb", _reset_smb)
await _cleanup_step("db", engine.dispose)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.storage = None
app.state.nats = None
app.state.exchange = None
logger.info("Subject'ы NATS. {subjects}", subjects=used_subjects(settings.OPENBAO.NATS))
await _register_smb()
try:
app.state.storage = await _connect_s3()
app.state.nats = _create_nats()
if app.state.nats is not None:
await app.state.nats.start() # подключение в фоне: старт приложения не ждёт NATS
app.state.exchange = _start_exchange(app.state.nats, app.state.storage) # публикатор ждёт NATS сам
logger.info("Приложение запущено")
yield
finally:
logger.info("Приложение закрывается")
await _shutdown(app)
def cors_options(app_settings: Settings) -> dict[str, Any] | None:
"""Источники CORS (OQ-65): DEBUG — `*` без credentials (ревью R-11; токен — в заголовке, cookie не нужны);
иначе — `CORS_ORIGINS`.
Пустой `CORS_ORIGINS` вне DEBUG — `None`: middleware не подключается, браузер кросс-доменные запросы не выполнит.
`allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials).
"""
if app_settings.DEBUG:
return {"allow_origins": ["*"], "allow_credentials": False}
origins = list(app_settings.CORS_ORIGINS)
if not origins:
return None
return {"allow_origins": origins, "allow_credentials": "*" not in origins}
#: пути документации API: при DEBUG=false маршрутов нет, а SPA-заглушка отвечает на них 404, а не index.html
DOC_PATHS = ("/docs", "/docs/oauth2-redirect", "/redoc", "/openapi.json")
def create_app(app_settings: Settings, *, web_dir: str = "web") -> FastAPI:
"""Приложение под настройки: документация API (`/docs`, `/redoc`, `/openapi.json`) — только при DEBUG (OQ-66),
CORS — `cors_options`, SPA из `web_dir`. Модульный `app` собран по настройкам процесса; тесты собирают свои."""
docs_enabled = app_settings.DEBUG
application = FastAPI(
title="АРМ КА",
version=VERSION,
# `/` → "" (OQ-62): при root_path "/" путь маршрута для `GET /` вычисляется пустым, монтирование SPA его не
# находит, и роутер отвечал редиректом на `//`
root_path=_normalize_root_path(app_settings.ROOT_PATH),
lifespan=lifespan,
docs_url="/docs" if docs_enabled else None,
redoc_url="/redoc" if docs_enabled else None,
openapi_url="/openapi.json" if docs_enabled else None,
)
@application.get("/healthcheck", status_code=200)
async def healthcheck(request: Request):
"""Живость приложения. ``nats``: ``off`` — NATS выключен, ``ok`` — подключён, ``down`` — нет связи."""
nats_status = "off"
if app_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,
"subjects": used_subjects(app_settings.OPENBAO.NATS),
}
application.include_router(build_api_router(app_settings))
# аудит входа (OQ-64): LOGIN первого запроса и LOGIN_FAILED — в отдельной транзакции, множество входов — на процесс
application.state.session_audit = SessionAudit(session_transaction)
register_exception_handlers(application)
application.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
# X-Forwarded-For — только от доверенных прокси: иначе любой клиент подставит свой ip в audit_event (OQ-65)
application.add_middleware(ProxyHeadersMiddleware, trusted_hosts=app_settings.FORWARDED_ALLOW_IPS)
cors = cors_options(app_settings)
if cors is not None:
application.add_middleware(CORSMiddleware, allow_methods=["*"], allow_headers=["*"], **cors)
# --- Раздача собранного SPA (web/) ---
os.makedirs(web_dir, exist_ok=True)
application.mount("/", SPAStaticFiles(directory=web_dir, html=True), name="web")
return application
def sep_static(path: str, stat_dir: str) -> str:
sep_path = path.split("/")
index = sep_path.index(stat_dir)
return "/".join(sep_path[index:])
API_STATIC_PATH = API_PREFIX.strip("/")
_DOC_STATIC_PATHS = frozenset(path.strip("/") for path in DOC_PATHS)
METHOD_NOT_ALLOWED_DETAIL = "метод не поддерживается этим маршрутом"
def _allowed_methods(scope: Scope) -> set[str]:
"""Методы маршрутов приложения, чей путь совпал с запросом, а метод — нет (`Match.PARTIAL`).
SPA смонтировано на `/` и совпадает с любым запросом полностью, поэтому роутер Starlette до своего 405 не доходит:
неверный метод на существующий маршрут API попадает сюда. Монтирования (в том числе само SPA) не учитываются.
"""
allowed: set[str] = set()
for route in scope["app"].router.routes:
if isinstance(route, Mount) or not isinstance(route, Route):
continue
match, _ = route.matches(scope)
if match is Match.PARTIAL and route.methods:
allowed |= route.methods
return allowed
class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope: Scope):
# Под /api/v1 SPA не отвечает (OQ-61): путь совпал с маршрутом API, но метод другой — 405 с `Allow`;
# маршрута нет — честный 404 (в т.ч. для POST), а не index.html SPA и не 405 от StaticFiles: иначе dev-маршруты,
# которых при выключенных флагах нет, выглядели бы как «существующие»
if path == API_STATIC_PATH or path.startswith(API_STATIC_PATH + "/"):
allowed = _allowed_methods(scope)
if allowed:
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
detail=METHOD_NOT_ALLOWED_DETAIL,
headers={"Allow": ", ".join(sorted(allowed))},
)
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
if path in _DOC_STATIC_PATHS:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
try:
return await super().get_response(path, scope)
except (HTTPException, StarletteHTTPException) as ex:
if ex.status_code == HTTPStatus.NOT_FOUND:
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 = create_app(settings)