From e92bf907fe3dd96d8011dce3c467d3d4b1bb414a Mon Sep 17 00:00:00 2001 From: cheykrym Date: Fri, 27 Jun 2025 23:16:48 +0300 Subject: [PATCH] init --- .gitignore | 130 ++++++++++++++++++ README.md | 0 common_lib/__init__.py | 0 common_lib/core/__init__.py | 0 common_lib/core/rate_limit.py | 4 + common_lib/docs/__init__.py | 0 common_lib/docs/errors.py | 119 +++++++++++++++++ common_lib/middlewares/__init__.py | 0 common_lib/middlewares/error_handler.py | 168 ++++++++++++++++++++++++ common_lib/models/__init__.py | 1 + common_lib/models/db.py | 26 ++++ common_lib/utils/__init__.py | 0 common_lib/utils/auth.py | 160 ++++++++++++++++++++++ pyproject.toml | 16 +++ 14 files changed, 624 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 common_lib/__init__.py create mode 100644 common_lib/core/__init__.py create mode 100644 common_lib/core/rate_limit.py create mode 100644 common_lib/docs/__init__.py create mode 100644 common_lib/docs/errors.py create mode 100644 common_lib/middlewares/__init__.py create mode 100644 common_lib/middlewares/error_handler.py create mode 100644 common_lib/models/__init__.py create mode 100644 common_lib/models/db.py create mode 100644 common_lib/utils/__init__.py create mode 100644 common_lib/utils/auth.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f51ee65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,130 @@ +# +test_modules/ +config/SSL/fullchain.pem +config/SSL/privkey.pem +logs/ +SECRET_KEY.key + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/common_lib/__init__.py b/common_lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common_lib/core/__init__.py b/common_lib/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common_lib/core/rate_limit.py b/common_lib/core/rate_limit.py new file mode 100644 index 0000000..38404a8 --- /dev/null +++ b/common_lib/core/rate_limit.py @@ -0,0 +1,4 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/common_lib/docs/__init__.py b/common_lib/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common_lib/docs/errors.py b/common_lib/docs/errors.py new file mode 100644 index 0000000..57108b4 --- /dev/null +++ b/common_lib/docs/errors.py @@ -0,0 +1,119 @@ +COMMON_ERROR_RESPONSES = { + 400: { + "description": "Bad Request", + "content": { + "application/json": { + "example": { + "status": "error", + "errors": [ + {"field": "general", "message": "Bad request syntax or invalid parameters"} + ] + } + } + } + }, + 401: { + "description": "Unauthorized", + "content": { + "application/json": { + "example": { + "status": "error", + "errors": [ + {"field": "login", "message": "Invalid login or password"} + ] + } + } + } + }, + 403: { + "description": "Forbidden", + "content": { + "application/json": { + "example": { + "status": "error", + "errors": [ + {"field": "permission", "message": "You don't have access to this resource"} + ] + } + } + } + }, + 404: { + "description": "Not Found", + "content": { + "application/json": { + "example": { + "status": "error", + "errors": [ + {"field": "resource", "message": "Requested resource not found"} + ] + } + } + } + }, + 405: { + "description": "Method Not Allowed", + "content": { + "application/json": { + "example": { + "status": "error", + "errors": [ + {"field": "method", "message": "Method not allowed on this endpoint"} + ] + } + } + } + }, + 409: { + "description": "Conflict", + "content": { + "application/json": { + "example": { + "status": "error", + "errors": [ + {"field": "conflict", "message": "Resource already exists or conflict occurred"} + ] + } + } + } + }, + 418: { + "description": "I'm a teapot (In Development)", + "content": { + "application/json": { + "example": { + "status": "error", + "errors": [ + {"field": "debug", "message": "This feature is under development"} + ] + } + } + } + }, + 422: { + "description": "Validation Error", + "content": { + "application/json": { + "example": { + "status": "error", + "errors": [ + {"field": "login", "message": "Login must not contain whitespace characters"} + ] + } + } + } + }, + 500: { + "description": "Internal Server Error", + "content": { + "application/json": { + "example": { + "status": "error", + "errors": [ + {"field": "server", "message": "An unexpected error occurred. Please try again later."} + ] + } + } + } + } +} diff --git a/common_lib/middlewares/__init__.py b/common_lib/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common_lib/middlewares/error_handler.py b/common_lib/middlewares/error_handler.py new file mode 100644 index 0000000..0b385cc --- /dev/null +++ b/common_lib/middlewares/error_handler.py @@ -0,0 +1,168 @@ +import os +import uuid +import logging +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.status import ( + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_405_METHOD_NOT_ALLOWED, + HTTP_409_CONFLICT, + HTTP_418_IM_A_TEAPOT, + HTTP_422_UNPROCESSABLE_ENTITY, +) +from datetime import datetime +from config import settings + +# Настраиваем логирование +logging.basicConfig(level=logging.ERROR) +logger = logging.getLogger(__name__) + + +def register_error_handlers(app: FastAPI): + + # Кастомный обработчик для 400 ошибки (BAD_REQUEST) + @app.exception_handler(HTTP_400_BAD_REQUEST) + async def bad_request_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=HTTP_400_BAD_REQUEST, + content={ + "status": "error", + "errors": [{"field": "general", "message": exc.detail or "Bad request syntax or invalid parameters"}] + } + ) + + # Кастомный обработчик для 401 ошибки (UNAUTHORIZED) + @app.exception_handler(HTTP_401_UNAUTHORIZED) + async def unauthorized_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=HTTP_401_UNAUTHORIZED, + content={ + "status": "error", + "errors": [{"field": "login", "message": exc.detail or "Unauthorized"}] + } + ) + + # Кастомный обработчик для 403 ошибки (FORBIDDEN) + @app.exception_handler(HTTP_403_FORBIDDEN) + async def forbidden_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=HTTP_403_FORBIDDEN, + content={ + "status": "error", + "errors": [{"field": "permission", "message": exc.detail or "Forbidden"}] + } + ) + + # Кастомный обработчик для 404 ошибки (NOT_FOUND) + @app.exception_handler(404) + async def custom_404_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=HTTP_404_NOT_FOUND, + content={ + "status": "error", + "errors": [{"field": "resource", "message": exc.detail or "Resource not found."}] + } + ) + + # Кастомный обработчик для 405 ошибки + @app.exception_handler(405) + async def custom_405_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=HTTP_405_METHOD_NOT_ALLOWED, + content={ + "status": "error", + "errors": [{"field": "request", "message": exc.detail or "Method not allowed."}] + } + ) + + # Кастомный обработчик для 409 ошибки (CONFLICT) (message: already exists) + @app.exception_handler(HTTP_409_CONFLICT) + async def conflict_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=HTTP_409_CONFLICT, + content={ + "status": "error", + "errors": [{"field": "conflict", "message": exc.detail or "Conflict"}] + } + ) + + # Кастомный обработчик для 418 ошибки (IM_A_TEAPOT) (message: в разработке) + @app.exception_handler(HTTP_418_IM_A_TEAPOT) + async def teapot_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=HTTP_418_IM_A_TEAPOT, + content={ + "status": "error", + "errors": [{"field": "debug", "message": exc.detail or "This feature is under development"}] + } + ) + + # Кастомный обработчик валидатора 422 + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + errors = exc.errors() + formatted_errors = [] + + for err in errors: + field_path = ".".join(str(loc) for loc in err.get("loc", []) if isinstance(loc, str)) + formatted_errors.append({ + "field": field_path, + "message": err.get("msg", "Validation error") + }) + + return JSONResponse( + status_code=HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "status": "error", + "errors": formatted_errors + } + ) + + # Универсальный обработчик HTTP ошибок + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=exc.status_code, + content={ + "status": "error", + "errors": [{"field": "request", "message": exc.detail or "HTTP error"}] + } + ) + + # Обработчик Internal Server Error + @app.exception_handler(Exception) + async def general_exception_handler(request: Request, exc: Exception): + # logger.error(f"Unhandled error: {exc}") + + LOG_DIR = settings.LOG_DIR + os.makedirs(LOG_DIR, exist_ok=True) + # Уникальный код ошибки (первые 8 символов UUID) + error_id = str(uuid.uuid4())[:8] + date_str = datetime.now().strftime("%Y-%m-%d") # Текущая дата (YYYY-MM-DD) + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Полное время ошибки + + log_filename = os.path.join(LOG_DIR, f"{error_id}_{date_str}.log") + + full_log_message = ( + f"{'-' * 50}\n" + f"Timestamp: {timestamp}\n" + f"Error ID: {error_id}\n" + f"Message: {exc}\n" + f"{'-' * 50}\n" + ) + + with open(log_filename, "w", encoding="utf-8") as log_file: + log_file.write(full_log_message) + + print(f"critical error: {LOG_DIR}/{error_id}_{date_str}.log") + return JSONResponse( + status_code=500, + content={ + "status": "error", + "errors": [{"field": "server", "message": "Internal Server Error"}] + } + ) diff --git a/common_lib/models/__init__.py b/common_lib/models/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/common_lib/models/__init__.py @@ -0,0 +1 @@ + diff --git a/common_lib/models/db.py b/common_lib/models/db.py new file mode 100644 index 0000000..6876e73 --- /dev/null +++ b/common_lib/models/db.py @@ -0,0 +1,26 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import declarative_base +from config import settings # поправил импорт — без .config + +# Создаем асинхронный движок +engine = create_async_engine(settings.DATABASE_URL, echo=False, future=True) + +# Создаем фабрику сессий +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False +) + +# Базовый класс для моделей +Base = declarative_base() + +# Зависимость для FastAPI + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() diff --git a/common_lib/utils/__init__.py b/common_lib/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common_lib/utils/auth.py b/common_lib/utils/auth.py new file mode 100644 index 0000000..e6b9f6c --- /dev/null +++ b/common_lib/utils/auth.py @@ -0,0 +1,160 @@ +import httpx +import re +from fastapi import Depends, Request, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from typing import List + +from dataclasses import dataclass +from config import settings + +auth_scheme = HTTPBearer() + + +@dataclass +class CurrentUser: + user_id: str + session_id: str + permissions: List[str] + + +async def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(auth_scheme) +): + token = credentials.credentials + ip = request.client.host or "(unknown)" + user_agent = request.headers.get("User-Agent", "(unknown)") + + try: + async with httpx.AsyncClient(verify=False) as client: + response = await client.post( + f"{settings.TOKEN_SERVICE}/decode", + json={ + "token": token, + "ip": ip, + "user_agent": user_agent + }, + timeout=5.0 + ) + except httpx.RequestError: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Token service unavailable") + + if response.status_code != 200: + # raise HTTPException(status_code=response.status_code, detail=response.json().get("detail", "Invalid token")) + raise HTTPException( + status_code=response.status_code, + detail=f"token_service: {response.text}" + ) + + data = response.json() + + return CurrentUser( + user_id=data["user_id"], + session_id=data["session_id"], + permissions=data["permissions"] + ) + + +def validate_username(value: str, + field_name: str = "login", + with_httpexception=False, + need_back=False, + min_length=3, + max_length=32, + error_status_code: int = status.HTTP_401_UNAUTHORIZED, + error_detail: str = "Invalid login or password") -> str: + """ + Username validator: + - checks for length + - no spaces + - no leading underscore + - no consecutive underscores + - only [A-Za-z0-9_] + """ + # Проверка типа и длины + if not isinstance(value, str) or not (min_length <= len(value) <= max_length): + msg = f"{field_name.capitalize()} must be between {min_length} and {max_length} characters long" + if with_httpexception: + raise HTTPException(status_code=error_status_code, detail=error_detail) + if need_back: + return False, msg + raise ValueError(msg) + + # Пробелы + if any(c.isspace() for c in value): + msg = f"{field_name.capitalize()} must not contain whitespace characters" + if with_httpexception: + raise HTTPException(status_code=error_status_code, detail=error_detail) + if need_back: + return False, msg + raise ValueError(msg) + + # Начинается с подчеркивания + if value.startswith('_'): + msg = f"{field_name.capitalize()} must not start with an underscore" + if with_httpexception: + raise HTTPException(status_code=error_status_code, detail=error_detail) + if need_back: + return False, msg + raise ValueError(msg) + + # Двойные подчеркивания + if '__' in value: + msg = f"{field_name.capitalize()} must not contain consecutive underscores" + if with_httpexception: + raise HTTPException(status_code=error_status_code, detail=error_detail) + if need_back: + return False, msg + raise ValueError(msg) + + # Только допустимые символы + if not re.fullmatch(r'[A-Za-z0-9_]+', value): + msg = f"{field_name.capitalize()} must contain only English letters, digits, and underscores" + if with_httpexception: + raise HTTPException(status_code=error_status_code, detail=error_detail) + if need_back: + return False, msg + raise ValueError(msg) + + if need_back: + return True, value.lower() + return value.lower() + + +def validate_password(value: str, + field_name: str = "password", + with_httpexception=False, + need_back=False, + min_length=8, + max_length=128, + error_status_code: int = status.HTTP_401_UNAUTHORIZED, + error_detail: str = "Invalid login or password") -> str: + """ + Validates password length and (optionally) other rules. + Supports HTTPException raising if `with_httpexception=True`. + Returns (True, value) or raises ValueError. + """ + # Проверка типа и длины + if not isinstance(value, str) or not (min_length <= len(value) <= max_length): + msg = f"{field_name.capitalize()} must be between {min_length} and {max_length} characters long" + if with_httpexception: + raise HTTPException(status_code=error_status_code, detail=error_detail) + if need_back: + return False, msg + raise ValueError(msg) + + # if any(c.isspace() for c in value): + # raise ValueError(f"{field_name.capitalize()} must not contain whitespace characters") + + # if not re.search(r'[A-Z]', value): + # raise ValueError(f"{field_name.capitalize()} must contain at least one uppercase letter") + + # if not re.search(r'\d', value): + # raise ValueError(f"{field_name.capitalize()} must contain at least one digit") + + # if not re.search(r'[!@#$%^&*()\-_=+\[\]{};:\'",.<>?/|\\]', value): + # raise ValueError(f"{field_name.capitalize()} must contain at least one special character") + + if need_back: + return True, value + return value diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a90090d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "common-lib" +version = "0.0.1" +description = "Библиотека общих компонентов для микросервисов yobble" +authors = [{ name = "cheykrym", email = "you@example.com" }] +license = "CHEYKRYM" +dependencies = [ + "fastapi", + "sqlalchemy", + "httpx", + "slowapi" +] + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta"