From 49bcb38261bc75881880c7d44a74051409266b45 Mon Sep 17 00:00:00 2001 From: Lino Mallevaey Date: Mon, 18 Aug 2025 00:22:50 +0200 Subject: [PATCH] feat: add base FastAPI project structure with static frontend and API v1 --- .env.sample | 85 +++++++++++++++++++++++++++++++++++++++++ .gitignore | 3 ++ app/__init__.py | 10 +++++ app/__main__.py | 19 +++++++++ app/api/v1/__init__.py | 10 +++++ app/api/v1/endpoints.py | 44 +++++++++++++++++++++ app/core/__init__.py | 19 +++++++++ app/core/config.py | 75 ++++++++++++++++++++++++++++++++++++ app/core/security.py | 76 ++++++++++++++++++++++++++++++++++++ app/db/__init__.py | 10 +++++ app/db/engine.py | 24 ++++++++++++ app/db/session.py | 45 ++++++++++++++++++++++ app/main.py | 49 ++++++++++++++++++++++++ requirements.txt | 31 ++++++++------- 14 files changed, 487 insertions(+), 13 deletions(-) create mode 100644 app/__main__.py diff --git a/.env.sample b/.env.sample index e69de29..9c39b19 100644 --- a/.env.sample +++ b/.env.sample @@ -0,0 +1,85 @@ +# ========================= +# ENVIRONMENT CONFIGURATION +# ========================= +ENV=dev # dev, prod + +# ========================= +# DATABASE CONFIGURATION +# ========================= +# URL de connexion SQLAlchemy +# Pour PyMySQL (synchrone) +DATABASE_URL=mysql+pymysql://user:password@localhost:3306/db_name + +# Pour aiomysql (async) +# DATABASE_URL=mysql+aiomysql://user:password@localhost:3306/mokpyo + +# Pooling / Options SQLAlchemy (optionnel) +# MAX_CONNECTIONS=10 +# MIN_CONNECTIONS=1 +# POOL_RECYCLE=3600 + +# ========================= +# FASTAPI SETTINGS +# ========================= +# Debug mode (True/False) +DEBUG=True + +# Nom et version de l’API +APP_TITLE=MokPyo +APP_VERSION=1.0.0 +APP_DESCRIPTION=MokPyo +APP_DOMAIN=localhost + +# ========================= +# SERVER CONFIGURATION +# ========================= +# Host et port de l’API +HOST=0.0.0.0 +PORT=8000 + +# ========================= +# SECURITY / AUTH +# ========================= +# Clé secrète pour JWT +SECRET_KEY=change_me_to_a_long_random_string + +# Expiration des tokens JWT en minutes +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# Algorithme de chiffrement JWT +ALGORITHM=HS256 + +# ========================= +# EMAIL / VALIDATION +# ========================= +# SMTP Server (pour envoyer emails, si nécessaire) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your_email@example.com +SMTP_PASSWORD=super_secret_password +EMAIL_FROM=no-reply@example.com + +# ========================= +# LOGGING +# ========================= +# Niveau de log : DEBUG, INFO, WARNING, ERROR, CRITICAL +LOG_LEVEL=INFO +LOG_FILE=logs/app.log + +# ========================= +# STATIC FILES / FRONTEND +# ========================= +# Répertoire static +STATIC_DIR=app/static + +# ========================= +# OPTIONAL / CUSTOM +# ========================= +# Nombre de workers uvicorn +UVICORN_WORKERS=4 + +# Limite upload fichiers (en bytes) +MAX_UPLOAD_SIZE=10485760 # 10MB + +# Mode ORJSON strict (True/False) +ORJSON_STRICT=True diff --git a/.gitignore b/.gitignore index 4577330..d2bb9d1 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,6 @@ cython_debug/ # vscode folder .vscode/ + +# git +.git/ diff --git a/app/__init__.py b/app/__init__.py index e69de29..1a0bfbc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1,10 @@ +""" +MokPyo application package. + +- Central entry point for app-level imports +""" + +from .main import app +from .core import settings + +__version__ = settings.APP_VERSION \ No newline at end of file diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..36f149b --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,19 @@ +""" +Entrypoint pour lancer l'application en dev. + +Usage: + python3 -m app +""" + +import uvicorn +from app.main import app +from app.core.config import settings + +if __name__ == "__main__": + uvicorn.run( + "app.main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.ENV == "dev", + log_level=settings.LOG_LEVEL.lower(), + ) diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index e69de29..483bd36 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -0,0 +1,10 @@ +""" +API v1 package for MokPyo application. + +- Central router for version 1 of the API +""" + +from fastapi import APIRouter +from .endpoints import router as v1_router + +__version__ = "1.0.0" \ No newline at end of file diff --git a/app/api/v1/endpoints.py b/app/api/v1/endpoints.py index e69de29..e8da2cb 100644 --- a/app/api/v1/endpoints.py +++ b/app/api/v1/endpoints.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.core import settings, get_current_user, hash_password, verify_password +from app.db import get_db +from pathlib import Path + +router = APIRouter() + + +# ========================= +# TEST / PING +# ========================= +@router.get("/ping", tags=["Test"]) +def ping(): + return {"message": "pong"} + +# ========================= +# EXEMPLE UTILISATEUR +# ========================= +@router.get("/me", tags=["User"]) +def read_current_user(current_user: dict = Depends(get_current_user)): + """ + Retourne les infos de l'utilisateur connecté + """ + return {"user": current_user} + +# ========================= +# EXEMPLE AUTH +# ========================= +@router.post("/hash-password", tags=["Auth"]) +def test_hash_password(password: str): + """ + Exemple simple pour hasher un mot de passe + """ + hashed = hash_password(password) + return {"password": password, "hashed": hashed} + +@router.post("/verify-password", tags=["Auth"]) +def test_verify_password(password: str, hashed: str): + """ + Vérifie qu'un mot de passe correspond à un hash + """ + valid = verify_password(password, hashed) + return {"valid": valid} diff --git a/app/core/__init__.py b/app/core/__init__.py index e69de29..9741169 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -0,0 +1,19 @@ +""" +Core module for MokPyo application. + +- Central access point for configuration and security utilities +""" + +# Expose key components for easy imports +from .config import settings +from .security import ( + hash_password, + verify_password, + create_access_token, + decode_access_token, + get_current_user, + set_auth_cookie, + get_cookie_token, +) + +__version__ = settings.APP_VERSION diff --git a/app/core/config.py b/app/core/config.py index e69de29..58c722c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -0,0 +1,75 @@ +from pydantic_settings import BaseSettings +from datetime import timedelta + +class Settings(BaseSettings): + # ========================= + # ENVIRONMENT + # ========================= + ENV: str + + # ========================= + # DATABASE + # ========================= + DATABASE_URL: str + + # ========================= + # FASTAPI + # ========================= + DEBUG: bool = True + APP_TITLE: str = "MokPyo API" + APP_VERSION: str = "1.0.0" + APP_DESCRIPTION: str = "MokPyo" + APP_DOMAIN: str = "localhost" + STATIC_DIR: str = "app/static" + + # ========================= + # SERVER + # ========================= + HOST: str = "0.0.0.0" + PORT: int = 8000 + UVICORN_WORKERS: int = 4 + + # ========================= + # SECURITY / AUTH + # ========================= + SECRET_KEY: str + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + ALGORITHM: str = "HS256" + + # ========================= + # EMAIL (optionnel) + # ========================= + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + EMAIL_FROM: str = "" + + # ========================= + # LOGGING + # ========================= + LOG_LEVEL: str = "INFO" + LOG_FILE: str = "logs/app.log" + + # ========================= + # UPLOADS / FILES + # ========================= + MAX_UPLOAD_SIZE: int = 10_485_760 # 10 MB + + # ========================= + # ORJSON + # ========================= + ORJSON_STRICT: bool = True + + @property + def access_token_expire(self) -> timedelta: + return timedelta(minutes=self.ACCESS_TOKEN_EXPIRE_MINUTES) + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + +# Instance globale +settings = Settings() + +print("test") \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py index e69de29..5e7d953 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -0,0 +1,76 @@ +from datetime import datetime, timedelta +from typing import Any, Optional + +from passlib.context import CryptContext +from jose import jwt, JWTError + +from fastapi import HTTPException, status, Depends, Request, Response +from fastapi.security import OAuth2PasswordBearer + +from app.core.config import settings + +# ========================= +# PASSWORD HASHING +# ========================= +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + return pwd_context.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash.""" + return pwd_context.verify(plain_password, hashed_password) + +# ========================= +# JWT TOKEN MANAGEMENT +# ========================= +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token") + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT access token.""" + to_encode = data.copy() + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + +def decode_access_token(token: str) -> dict: + """Decode and verify a JWT access token.""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") + +# ========================= +# DEPENDENCIES FASTAPI +# ========================= +async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict: + """Get current user from JWT token.""" + payload = decode_access_token(token) + user_id: Any = payload.get("sub") + if user_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") + return payload + +# ========================= +# COOKIE MANAGEMENT (optionnel) +# ========================= +def set_auth_cookie(response: Response, token: str, max_age: int = 3600): + """Set JWT token in HttpOnly cookie.""" + response.set_cookie( + key="access_token", + value=f"Bearer {token}", + httponly=True, + max_age=max_age, + samesite="lax", + secure=False, # True si HTTPS + ) + +def get_cookie_token(request: Request) -> Optional[str]: + """Get JWT token from cookies.""" + cookie = request.cookies.get("access_token") + if cookie and cookie.startswith("Bearer "): + return cookie[7:] + return None diff --git a/app/db/__init__.py b/app/db/__init__.py index e69de29..c2d69a0 100644 --- a/app/db/__init__.py +++ b/app/db/__init__.py @@ -0,0 +1,10 @@ +""" +Database package for MokPyo application. + +- Expose engine and session objects for easy imports +""" + +__version__ = "1.0.0" + +from .engine import engine, async_engine +from .session import SessionLocal, AsyncSessionLocal, get_db, get_async_db diff --git a/app/db/engine.py b/app/db/engine.py index e69de29..99ee3c3 100644 --- a/app/db/engine.py +++ b/app/db/engine.py @@ -0,0 +1,24 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine +from sqlalchemy import create_engine +from app.core.config import settings + +# ========================= +# SYNCHRONOUS ENGINE +# ========================= +# Pour opérations sync classiques +engine = create_engine( + settings.DATABASE_URL.replace("+aiomysql", ""), # remove async part if present + echo=settings.DEBUG, + future=True +) + +# ========================= +# ASYNCHRONOUS ENGINE +# ========================= +# Pour opérations async avec async SQLAlchemy +async_engine: AsyncEngine = create_async_engine( + settings.DATABASE_URL if "+aiomysql" in settings.DATABASE_URL else settings.DATABASE_URL.replace("mysql+pymysql", "mysql+aiomysql"), + echo=settings.DEBUG, + future=True +) + diff --git a/app/db/session.py b/app/db/session.py index e69de29..ececf74 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -0,0 +1,45 @@ +from typing import AsyncGenerator, Generator +from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession +from app.db.engine import engine, async_engine + +# ========================= +# SYNCHRONOUS SESSION +# ========================= +SessionLocal = sessionmaker( + bind=engine, + autocommit=False, + autoflush=False, + future=True +) + +def get_db() -> Generator: + """ + Yield a synchronous SQLAlchemy session + Usage: Depends(get_db) + """ + db = SessionLocal() + try: + yield db + finally: + db.close() + +# ========================= +# ASYNCHRONOUS SESSION +# ========================= +AsyncSessionLocal = sessionmaker( + bind=async_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, + future=True +) + +async def get_async_db() -> AsyncGenerator: + """ + Yield an asynchronous SQLAlchemy session + Usage: Depends(get_async_db) + """ + async with AsyncSessionLocal() as session: + yield session diff --git a/app/main.py b/app/main.py index e69de29..5bfd13a 100644 --- a/app/main.py +++ b/app/main.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI +from fastapi.responses import ORJSONResponse, JSONResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from pathlib import Path + +# Import de la config +from app.core.config import settings + +# Import du router v1 +from app.api.v1 import v1_router + +# Création de l'app FastAPI +app = FastAPI( + title=settings.APP_TITLE, + version=settings.APP_VERSION, + default_response_class=ORJSONResponse if settings.ORJSON_STRICT else JSONResponse, + debug=settings.DEBUG, + + # Ajout de la documentation + openapi_url=None if settings.ENV == "prod" else "/openapi.json", + docs_url=None if settings.ENV == "prod" else "/docs", + redoc_url=None if settings.ENV == "prod" else "/redoc", +) + +# ========================= +# Static files +# ========================= + +STATIC_PATH = Path(__file__).parent / "static" +app.mount("/assets", StaticFiles(directory=STATIC_PATH / "assets"), name="assets") + +# ========================= +# Inclure les routes v1 +# ========================= +app.include_router(v1_router, prefix="/api/v1") + + +# ========================= +# Static pages +# ========================= + +def add_page(endpoint: str, html_file: str): + file_path = STATIC_PATH / html_file + @app.get(endpoint, include_in_schema=False) + def page(): + return FileResponse(file_path) + + +add_page("/", "index.html") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c2b6696..027c58c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,18 @@ -fastapi[all] -uvicorn[standard] -sqlmodel -alembic -passlib[bcrypt] -pydantic-settings -ruff -black -isort -pytest -pytest-asyncio -httpx -gunicorn \ No newline at end of file +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +pydantic-settings==2.5.2 + +sqlalchemy==2.0.43 +alembic==1.13.2 +pymysql==1.1.0 +aiomysql==0.2.0 + +passlib[bcrypt]==1.7.4 +python-jose[cryptography]==3.3.0 +email-validator==2.2.0 + +pytest==8.3.2 +pytest-asyncio==0.23.8 +httpx==0.27.2 + +orjson==3.9.11 \ No newline at end of file