초기 커밋
This commit is contained in:
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
93
app/core/config.py
Normal file
93
app/core/config.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# ── Application ──────────────────────────────────
|
||||
APP_NAME: str = "core-api"
|
||||
APP_ENV: str = "development"
|
||||
DEBUG: bool = True
|
||||
SECRET_KEY: str = "change-me-to-a-random-secret-key"
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# ── MariaDB ──────────────────────────────────────
|
||||
MARIADB_HOST: str = "127.0.0.1"
|
||||
MARIADB_PORT: int = 3306
|
||||
MARIADB_USER: str = "root"
|
||||
MARIADB_PASSWORD: str = "changeme"
|
||||
MARIADB_DATABASE: str = "core_api"
|
||||
|
||||
@property
|
||||
def MARIADB_DSN(self) -> str:
|
||||
return (
|
||||
f"mysql+aiomysql://{self.MARIADB_USER}:{self.MARIADB_PASSWORD}"
|
||||
f"@{self.MARIADB_HOST}:{self.MARIADB_PORT}/{self.MARIADB_DATABASE}"
|
||||
)
|
||||
|
||||
@property
|
||||
def MARIADB_DSN_SYNC(self) -> str:
|
||||
return (
|
||||
f"mysql+pymysql://{self.MARIADB_USER}:{self.MARIADB_PASSWORD}"
|
||||
f"@{self.MARIADB_HOST}:{self.MARIADB_PORT}/{self.MARIADB_DATABASE}"
|
||||
)
|
||||
|
||||
# ── MongoDB ──────────────────────────────────────
|
||||
MONGODB_URL: str = "mongodb://127.0.0.1:27017"
|
||||
MONGODB_DATABASE: str = "core_api"
|
||||
|
||||
# ── Redis ────────────────────────────────────────
|
||||
REDIS_URL: str = "redis://127.0.0.1:6379/0"
|
||||
|
||||
# ── JWT ──────────────────────────────────────────
|
||||
JWT_SECRET_KEY: str = "change-me-jwt-secret"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# ── MQTT ─────────────────────────────────────────
|
||||
MQTT_HOST: str = "127.0.0.1"
|
||||
MQTT_PORT: int = 1883
|
||||
MQTT_USERNAME: str = ""
|
||||
MQTT_PASSWORD: str = ""
|
||||
|
||||
# ── Celery ───────────────────────────────────────
|
||||
CELERY_BROKER_URL: str = "redis://127.0.0.1:6379/1"
|
||||
CELERY_RESULT_BACKEND: str = "redis://127.0.0.1:6379/2"
|
||||
|
||||
# ── CORS ─────────────────────────────────────────
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8080"]
|
||||
|
||||
@field_validator("CORS_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def assemble_cors_origins(cls, v: str | list[str]) -> list[str]:
|
||||
if isinstance(v, str):
|
||||
return [i.strip() for i in v.strip("[]").split(",") if i.strip()]
|
||||
return v
|
||||
|
||||
# ── OAuth ────────────────────────────────────────
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_CLIENT_SECRET: str = ""
|
||||
KAKAO_CLIENT_ID: str = ""
|
||||
KAKAO_CLIENT_SECRET: str = ""
|
||||
NAVER_CLIENT_ID: str = ""
|
||||
NAVER_CLIENT_SECRET: str = ""
|
||||
|
||||
# ── SMTP ─────────────────────────────────────────
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USERNAME: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
|
||||
# ── Logging ──────────────────────────────────────
|
||||
LOG_LEVEL: str = "DEBUG"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
31
app/core/constants.py
Normal file
31
app/core/constants.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class Role:
|
||||
SUPERADMIN = "superadmin"
|
||||
ADMIN = "admin"
|
||||
MANAGER = "manager"
|
||||
USER = "user"
|
||||
DEVICE = "device"
|
||||
|
||||
ALL = [SUPERADMIN, ADMIN, MANAGER, USER, DEVICE]
|
||||
ADMIN_ROLES = [SUPERADMIN, ADMIN]
|
||||
MANAGEMENT_ROLES = [SUPERADMIN, ADMIN, MANAGER]
|
||||
|
||||
|
||||
class DeviceStatus:
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
ERROR = "error"
|
||||
MAINTENANCE = "maintenance"
|
||||
|
||||
|
||||
class AlertSeverity:
|
||||
CRITICAL = "critical"
|
||||
WARNING = "warning"
|
||||
INFO = "info"
|
||||
|
||||
|
||||
class TokenType:
|
||||
ACCESS = "access"
|
||||
REFRESH = "refresh"
|
||||
36
app/core/dependencies.py
Normal file
36
app/core/dependencies.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.constants import TokenType
|
||||
from app.core.exceptions import ForbiddenException, UnauthorizedException
|
||||
from app.core.security import decode_token
|
||||
|
||||
bearer_scheme = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user_payload(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
) -> dict:
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
raise UnauthorizedException("Invalid or expired token")
|
||||
if payload.get("type") != TokenType.ACCESS:
|
||||
raise UnauthorizedException("Invalid token type")
|
||||
return payload
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
payload: dict = Depends(get_current_user_payload),
|
||||
) -> int:
|
||||
return int(payload["sub"])
|
||||
|
||||
|
||||
def require_role(*allowed_roles: str):
|
||||
async def _check(payload: dict = Depends(get_current_user_payload)) -> dict:
|
||||
if payload.get("role") not in allowed_roles:
|
||||
raise ForbiddenException("Insufficient permissions")
|
||||
return payload
|
||||
|
||||
return _check
|
||||
22
app/core/error_handlers.py
Normal file
22
app/core/error_handlers.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.exceptions import AppException
|
||||
|
||||
|
||||
def register_error_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(AppException)
|
||||
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
32
app/core/exceptions.py
Normal file
32
app/core/exceptions.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class NotFoundException(AppException):
|
||||
def __init__(self, detail: str = "Resource not found"):
|
||||
super().__init__(status_code=404, detail=detail)
|
||||
|
||||
|
||||
class UnauthorizedException(AppException):
|
||||
def __init__(self, detail: str = "Not authenticated"):
|
||||
super().__init__(status_code=401, detail=detail)
|
||||
|
||||
|
||||
class ForbiddenException(AppException):
|
||||
def __init__(self, detail: str = "Permission denied"):
|
||||
super().__init__(status_code=403, detail=detail)
|
||||
|
||||
|
||||
class ConflictException(AppException):
|
||||
def __init__(self, detail: str = "Resource already exists"):
|
||||
super().__init__(status_code=409, detail=detail)
|
||||
|
||||
|
||||
class ValidationException(AppException):
|
||||
def __init__(self, detail: str = "Validation error"):
|
||||
super().__init__(status_code=422, detail=detail)
|
||||
43
app/core/logging_config.py
Normal file
43
app/core/logging_config.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import structlog
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
structlog.dev.ConsoleRenderer()
|
||||
if settings.DEBUG
|
||||
else structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=log_level,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
return structlog.get_logger(name)
|
||||
16
app/core/permissions.py
Normal file
16
app/core/permissions.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.constants import Role
|
||||
|
||||
|
||||
def is_admin(role: str) -> bool:
|
||||
return role in Role.ADMIN_ROLES
|
||||
|
||||
|
||||
def is_management(role: str) -> bool:
|
||||
return role in Role.MANAGEMENT_ROLES
|
||||
|
||||
|
||||
def can_manage_user(actor_role: str, target_role: str) -> bool:
|
||||
hierarchy = {Role.SUPERADMIN: 4, Role.ADMIN: 3, Role.MANAGER: 2, Role.USER: 1, Role.DEVICE: 0}
|
||||
return hierarchy.get(actor_role, 0) > hierarchy.get(target_role, 0)
|
||||
47
app/core/security.py
Normal file
47
app/core/security.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.constants import TokenType
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def create_access_token(subject: int | str, role: str) -> str:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
"role": role,
|
||||
"type": TokenType.ACCESS,
|
||||
"exp": expire,
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(subject: int | str) -> str:
|
||||
expire = datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
"type": TokenType.REFRESH,
|
||||
"exp": expire,
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
Reference in New Issue
Block a user