This commit is contained in:
cheykrym 2025-06-27 23:16:48 +03:00
commit e92bf907fe
14 changed files with 624 additions and 0 deletions

130
.gitignore vendored Normal file
View File

@ -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/

0
README.md Normal file
View File

0
common_lib/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)

View File

119
common_lib/docs/errors.py Normal file
View File

@ -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."}
]
}
}
}
}
}

View File

View File

@ -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"}]
}
)

View File

@ -0,0 +1 @@

26
common_lib/models/db.py Normal file
View File

@ -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()

View File

160
common_lib/utils/auth.py Normal file
View File

@ -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

16
pyproject.toml Normal file
View File

@ -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"