chore: упрощены app.py, config.py и requirements.txt после выреза шаблона
Убраны SPA, CORS, SMB, РАИСА, SQLAlchemy-обработчики и зависимости БД/SMB; настройки: ENV_FILE, SERVICE_NAME, NATS_ENABLED, WORKER_CONCURRENCY, PUBLISH_RETRIES, DONE_CACHE_SIZE, MINIO_SECURE. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b428f68e9e
commit
9cdc62b26d
@ -2,13 +2,9 @@ aiohappyeyeballs==2.7.1
|
||||
aiohttp==3.14.3
|
||||
aiohttp-retry==2.9.1
|
||||
aiosignal==1.4.0
|
||||
alembic==1.14.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.8.0
|
||||
anyio==4.14.2
|
||||
argon2-cffi==23.1.0
|
||||
argon2-cffi-bindings==25.1.0
|
||||
asyncpg==0.30.0
|
||||
attrs==26.1.0
|
||||
certifi==2025.11.12
|
||||
cffi==2.1.0
|
||||
@ -20,7 +16,6 @@ fastapi-cli==0.0.24
|
||||
fastapi-cloud-cli==0.14.1
|
||||
fastar==0.8.0
|
||||
frozenlist==1.8.0
|
||||
greenlet==3.4.0
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.7.1
|
||||
@ -28,7 +23,6 @@ httpx==0.28.1
|
||||
idna==3.18
|
||||
Jinja2==3.1.6
|
||||
loguru==0.7.3
|
||||
Mako==1.3.10
|
||||
markdown-it-py==4.2.0
|
||||
MarkupSafe==3.0.3
|
||||
mdurl==0.1.2
|
||||
@ -48,14 +42,11 @@ PyNaCl==1.5.0
|
||||
python-dotenv==1.2.2
|
||||
python-multipart==0.0.32
|
||||
PyYAML==6.0.3
|
||||
raisa_fastapi_protected_api==1.0.0
|
||||
rich==15.0.0
|
||||
rich-toolkit==0.19.7
|
||||
rignore==0.7.6
|
||||
sentry-sdk==2.66.1
|
||||
shellingham==1.5.4
|
||||
SQLAlchemy==2.0.36
|
||||
smbprotocol==1.15.0
|
||||
starlette==1.3.1
|
||||
typer==0.27.0
|
||||
typing-inspection==0.4.2
|
||||
|
||||
177
api/src/app.py
177
api/src/app.py
@ -1,21 +1,16 @@
|
||||
"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода.
|
||||
"""Точка входа FastAPI сервиса tabular-parser.
|
||||
|
||||
Что здесь есть: настройки, подключение 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).
|
||||
Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT; пустые seed/jwt — без аутентификации),
|
||||
middleware (gzip, proxy headers), healthcheck, обработчики исключений. Сервис без БД и фронта (DECISION-4).
|
||||
Воркер обработки `recognition.request` и поле `nats` в healthcheck добавляются позже (src/core/nats.py).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
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
|
||||
@ -26,120 +21,86 @@ 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
|
||||
from src.core.logger import setup_logger
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
VERSION = settings.VERSION
|
||||
UP_TIME = datetime.now(timezone.utc)
|
||||
UP_TIME = datetime.now(UTC)
|
||||
|
||||
setup_logger(level="DEBUG" if settings.DEBUG else "INFO")
|
||||
|
||||
|
||||
# --- NATS: аутентификация по nkeys/JWT ---------------------------------------------------------
|
||||
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8"))
|
||||
async def _connect_nats() -> NATS | None:
|
||||
"""Подключение к NATS (core, без JetStream); при ошибке — ``None``, приложение всё равно стартует."""
|
||||
nats_settings = settings.OPENBAO.NATS
|
||||
options: dict = {"max_reconnect_attempts": 2}
|
||||
if nats_settings.NATS_SEED and nats_settings.NATS_JWT:
|
||||
kp = nkeys.from_seed(nats_settings.NATS_SEED.encode("utf-8"))
|
||||
|
||||
def sig_cb(nonce: bytes | str) -> bytes:
|
||||
if isinstance(nonce, str):
|
||||
nonce = nonce.encode("utf-8")
|
||||
return base64.b64encode(kp.sign(nonce))
|
||||
|
||||
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:
|
||||
options |= {"user_jwt_cb": lambda: nats_settings.NATS_JWT.encode("utf-8"), "signature_cb": sig_cb}
|
||||
nc = NATS()
|
||||
try:
|
||||
from raisa_fastapi_protected_api import ( # pyright: ignore[reportMissingImports]
|
||||
AuthorizationMiddleware,
|
||||
OpenEndpoint,
|
||||
ProtectedOAuthSettings,
|
||||
ProtectedRolesSettings,
|
||||
ProtectedSettings,
|
||||
SearchType,
|
||||
await nc.connect(nats_settings.NATS_URL, **options)
|
||||
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),
|
||||
)
|
||||
|
||||
protected_middleware_available = True
|
||||
except ImportError:
|
||||
protected_middleware_available = False
|
||||
return None
|
||||
logger.info("Подключение к NATS произошло")
|
||||
return nc
|
||||
|
||||
|
||||
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")
|
||||
|
||||
async def _connect_s3() -> S3Storage | None:
|
||||
"""Клиент S3 с проверкой бакета; при недоступности — ``None`` (приложение всё равно стартует)."""
|
||||
minio = settings.OPENBAO.MINIO
|
||||
s3_client = Minio(
|
||||
settings.OPENBAO.MINIO.MINIO_URL,
|
||||
access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN,
|
||||
secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD,
|
||||
minio.MINIO_URL,
|
||||
access_key=minio.MINIO_ADMIN_LOGIN,
|
||||
secret_key=minio.MINIO_ADMIN_PASSWORD,
|
||||
secure=minio.MINIO_SECURE,
|
||||
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)
|
||||
bucket_exists = await s3_client.bucket_exists(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),
|
||||
)
|
||||
return
|
||||
app.state.storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
|
||||
await s3_client.close_session()
|
||||
return None
|
||||
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists)
|
||||
return S3Storage(client=s3_client, bucket_name=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
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.storage = await _connect_s3()
|
||||
app.state.nats = await _connect_nats() if settings.NATS_ENABLED else None
|
||||
if not settings.NATS_ENABLED:
|
||||
logger.info("NATS выключен (NATS_ENABLED=false)")
|
||||
|
||||
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:
|
||||
if app.state.nats:
|
||||
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 = FastAPI(title="tabular-parser", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/healthcheck", status_code=200)
|
||||
@ -153,37 +114,3 @@ 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")
|
||||
|
||||
@ -1,50 +1,62 @@
|
||||
import os
|
||||
|
||||
from pydantic import PositiveInt
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class DBSettings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
|
||||
class MinioSettings(BaseSettings):
|
||||
MINIO_URL: str
|
||||
MINIO_ADMIN_LOGIN: str
|
||||
MINIO_ADMIN_PASSWORD: str
|
||||
MINIO_BUCKET: str
|
||||
MINIO_SECURE: bool = True # https (платформа); false — http (MinIO из docker compose)
|
||||
|
||||
|
||||
class NATSSettings(BaseSettings):
|
||||
NATS_URL: str
|
||||
NATS_SEED: str
|
||||
NATS_JWT: str
|
||||
NATS_SEED: str = "" # пусто — подключение к NATS без nkeys (локальная разработка, тесты)
|
||||
NATS_JWT: str = ""
|
||||
|
||||
class SMBSettings(BaseSettings):
|
||||
SMB_HOST: str
|
||||
SMB_PATH: str
|
||||
SMB_LOGIN: str
|
||||
SMB_PASSWORD: str
|
||||
|
||||
class OpenBAOSettings(BaseSettings):
|
||||
DATABASE: DBSettings
|
||||
MINIO: MinioSettings
|
||||
NATS: NATSSettings
|
||||
SMB: SMBSettings
|
||||
|
||||
|
||||
class ServerSettings(BaseSettings):
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Настройки сервиса.
|
||||
|
||||
Файл окружения выбирается переменной ``ENV_FILE`` (по умолчанию ``.env``; значение читается при импорте модуля,
|
||||
относительный путь считается от текущего каталога — ``api/``). Тесты используют ``.env.test``.
|
||||
"""
|
||||
|
||||
OPENBAO: OpenBAOSettings
|
||||
DEBUG: bool = False
|
||||
server: ServerSettings = ServerSettings()
|
||||
ROOT_PATH: str = '/'
|
||||
VERSION: str = '1.0.0'
|
||||
ROOT_PATH: str = "/"
|
||||
VERSION: str = "1.0.0"
|
||||
|
||||
SERVICE_NAME: str = "tabular-parser" # `service.name` конвертов контракта и имя queue group NATS
|
||||
NATS_ENABLED: bool = True # false — приложение не подключается к NATS (воркер не стартует)
|
||||
WORKER_CONCURRENCY: PositiveInt = 1 # число заданий, обрабатываемых одновременно
|
||||
PUBLISH_RETRIES: PositiveInt = 3 # попытки публикации ack/результата в NATS
|
||||
DONE_CACHE_SIZE: PositiveInt = 1000 # размер process-local LRU обработанных job_id
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
extra="ignore",
|
||||
env_file=os.environ.get("ENV_FILE", ".env"),
|
||||
env_nested_delimiter="__",
|
||||
nested_model_default_partial_update=False,
|
||||
)
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
env_file = ".env"
|
||||
env_nested_delimiter = "__"
|
||||
nested_model_default_partial_update = False
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@ -12,20 +11,6 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
content={"detail": exc.errors()},
|
||||
)
|
||||
|
||||
@app.exception_handler(IntegrityError)
|
||||
async def integrity_error_handler(_: Request, exc: IntegrityError):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={"detail": "нарушение целостности данных в БД"},
|
||||
)
|
||||
|
||||
@app.exception_handler(SQLAlchemyError)
|
||||
async def sqlalchemy_error_handler(_: Request, exc: SQLAlchemyError):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "ошибка базы данных"},
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(_: Request, exc: HTTPException):
|
||||
# Не ломаем существующие ответы endpoint-ов и прокидываем исходный detail.
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
|
||||
### Task 2: [1.2] `src/app.py`
|
||||
|
||||
- [ ] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; `src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`, `SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: `pip install -r requirements.txt` в чистом venv (python3.11), `python run_server.py` стартует c `.env.example`, `/healthcheck` 200
|
||||
- [x] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; `src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`, `SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: `pip install -r requirements.txt` в чистом venv (python3.11), `python run_server.py` стартует c `.env.example`, `/healthcheck` 200
|
||||
|
||||
### Task 3: [1.3] `README.md` — описание сервиса (назначение, команды `make`, env, запуск против c
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
- [x] 1.1 Удалить `web/`, `setup/.npmrc`, npm-шаги из `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`,
|
||||
`api/src/models/`, `api/src/repositories/`; из `.gitignore` убрать `Dockerfile`; проверка: `git status` показывает
|
||||
только удаления и `.gitignore`; `python -c "import src"` пока падает ожидаемо (чинится в 1.2)
|
||||
- [ ] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`;
|
||||
- [x] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`;
|
||||
`src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`,
|
||||
`SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`;
|
||||
`requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user