init
This commit is contained in:
commit
e92bf907fe
130
.gitignore
vendored
Normal file
130
.gitignore
vendored
Normal 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
common_lib/__init__.py
Normal file
0
common_lib/__init__.py
Normal file
0
common_lib/core/__init__.py
Normal file
0
common_lib/core/__init__.py
Normal file
4
common_lib/core/rate_limit.py
Normal file
4
common_lib/core/rate_limit.py
Normal file
@ -0,0 +1,4 @@
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
0
common_lib/docs/__init__.py
Normal file
0
common_lib/docs/__init__.py
Normal file
119
common_lib/docs/errors.py
Normal file
119
common_lib/docs/errors.py
Normal 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."}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
0
common_lib/middlewares/__init__.py
Normal file
0
common_lib/middlewares/__init__.py
Normal file
168
common_lib/middlewares/error_handler.py
Normal file
168
common_lib/middlewares/error_handler.py
Normal 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"}]
|
||||
}
|
||||
)
|
1
common_lib/models/__init__.py
Normal file
1
common_lib/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
26
common_lib/models/db.py
Normal file
26
common_lib/models/db.py
Normal 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()
|
0
common_lib/utils/__init__.py
Normal file
0
common_lib/utils/__init__.py
Normal file
160
common_lib/utils/auth.py
Normal file
160
common_lib/utils/auth.py
Normal 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
16
pyproject.toml
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user