Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
push:
branches: ["main", "dev"]
pull_request:
branches: ["main", "dev"]

jobs:
lint-and-typecheck:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: "pyproject.toml"
- name: Install dependencies
run: uv sync --locked --all-extras --dev
- name: Lint
run: uv run flake8

- name: Type Check
run: uv run mypy .

# - name: Run tests
# run: uv run pytest --maxfail=1 --disable-warnings
52 changes: 44 additions & 8 deletions app/container.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,48 @@
from fastapi import Depends
import sqlalchemy.ext.asyncio
from fastapi import Depends
from app.infra.database import get_db
from db.generated import user as user_queries,session as session_queries,devices as device_queries
from app.infra.redis import RedisClient
from db.generated import user as user_queries
from db.generated import session as session_queries
from db.generated import devices as device_queries
from app.service.users import AuthService
from app.service.session import SessionService
from app.service.device import DeviceService



class Container:

def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection):
# infrastructure
self.redis = RedisClient.get_instance()

# queriers
self.user_querier = user_queries.AsyncQuerier(conn)
self.session_querier = session_queries.AsyncQuerier(conn)
self.device_querier = device_queries.AsyncQuerier(conn)

# services
self.session_service = SessionService()
self.session_service.init(
session=self.session_querier,
redis=self.redis,
)

self.device_service = DeviceService()
self.device_service.init(
device_querier=self.device_querier,
)

self.auth_service = AuthService(
user_querier=self.user_querier,
device_querier=self.device_querier,
session_querier=self.session_querier,
)


async def init_repo(conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db)):
user_querier:user_queries.AsyncQuerier = user_queries.AsyncQuerier(conn)
session_querier:session_queries.AsyncQuerier = session_queries.AsyncQuerier(conn)
device_querier :device_queries.AsyncQuerier = device_queries.AsyncQuerier(conn)
return user_querier,session_querier,device_querier


async def get_container(
conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db),
) -> Container:
return Container(conn)
4 changes: 4 additions & 0 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ def forbidden(detail: str = "Forbidden") -> HTTPException:
@staticmethod
def bad_request(detail: str = "Bad request") -> HTTPException:
return HTTPException(status_code=400, detail=detail)

@staticmethod
def payement_required(detail:str = "payement required")->HTTPException:
return HTTPException(status_code=402,detail=detail)

@staticmethod
def internal_error(detail: str = "Internal server error") -> HTTPException:
Expand Down
4 changes: 2 additions & 2 deletions app/core/securite.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import jwt
from passlib.context import CryptContext
import pyotp
from core.config import settings
from core.exceptions import AppException
from app.core.config import settings
from app.core.exceptions import AppException

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

Expand Down
74 changes: 34 additions & 40 deletions app/deps/auth.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,51 @@
from typing import Any
from typing import Annotated
import uuid
from fastapi import Depends
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.core import constant
from app.core.exceptions import AppException
from pydantic import BaseModel
from app.container import get_container, Container
from app.core.securite import decode_access_mobile_token
from app.infra.redis import RedisClient
from app.service.users import AuthService
from db.generated import user as user_queries
from db.generated import session as session_queries


security = HTTPBearer()


class MobileUserSchema(BaseModel):
user_id: uuid.UUID
email: str
session_id: uuid.UUID


async def get_current_mobile_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict[str, Any]:
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
container: Annotated[Container, Depends(get_container)],
) -> MobileUserSchema:
"""
Dependency to get the current logged-in mobile user.
Returns a strict Pydantic model.
"""
token = credentials.credentials
payload = decode_access_mobile_token(token)
session_id = payload.get("session_id")
session_id_str = payload.get("session_id")

if not session_id:
raise AppException.unauthorized("Invalid token")
if not session_id_str:
raise HTTPException(status_code=401, detail="Invalid token")

session_querier = session_queries.AsyncQuerier(conn)
session = await session_querier.get_session_by_id(id=uuid.UUID(session_id))
session_id = uuid.UUID(session_id_str)

# Validate session via SessionService
session = await container.session_service.session_querier.get_session_by_id(id=session_id)
if not session:
raise AppException.unauthorized("Session not found")

if session.expires_at.replace(tzinfo=None) < payload.get("exp", 0):
raise AppException.unauthorized("Session expired")

session_key = constant.RedisKey.UserSessionByUser.value.format(
user_id=session.user_id
)
redis_session = await redis.get(session_key)

if not redis_session or redis_session != session_id:
raise AppException.forbidden("Invalid session. Please login again.")

await redis.expire(session_key, AuthService.REDIS_SESSION_TTL)
raise HTTPException(status_code=401, detail="Session not found")

user_querier = user_queries.AsyncQuerier(conn)
user = await user_querier.get_user_by_id(id=session.user_id)
exp_ts = payload.get("exp")
if exp_ts and session.expires_at.timestamp() < exp_ts:
raise HTTPException(status_code=401, detail="Session expired")

user = await container.auth_service.user_querier.get_user_by_id(id=session.user_id)
if not user:
raise AppException.unauthorized("User not found")
raise HTTPException(status_code=401, detail="User not found")

return {
"user_id": str(user.id),
"email": user.email,
"session_id": session_id,
}
return MobileUserSchema(
user_id=user.id,
email=user.email,
session_id=session.id,
)
6 changes: 3 additions & 3 deletions app/infra/nats.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ async def publish(subject: NatsSubjects, message: bytes) -> None:
async def subscribe(subject: NatsSubjects, callback: Callable[[Any], Any]) -> None:
if NatsClient._nc is None:
await NatsClient.connect()

assert NatsClient._nc is not None
async def _wrapper(msg:Msg):
await callback(msg.data)

await NatsClient._nc.subscribe(subject.value, cb=_wrapper)#TODO:fix it here
await NatsClient._nc.subscribe(subject.value, cb=_wrapper)# type: ignore


@staticmethod
Expand All @@ -76,7 +76,7 @@ async def _wrapper(msg:Msg):
await msg.ack()
if NatsClient._js is None :
print("no client ")
await NatsClient._js.subscribe(
await NatsClient._js.subscribe( # type: ignore
subject=subject.value,
stream=stream_name,
durable=durable_name,
Expand Down
10 changes: 8 additions & 2 deletions app/infra/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ def __init__(self, host: str, port: int, password: str):
f"redis://{host}:{port}", password=password, decode_responses=True
)

async def set(self, key: RedisKey | str, value: str, expire: int | None = None):
await self.client.set(key, value, ex=expire)
async def set(self, key: RedisKey | str, value: str, expire: int | None = None,nx:bool=False):
await self.client.set(key, value, ex=expire,nx=nx)

async def get(self, key: RedisKey | str) -> str | None:
return await self.client.get(key)
Expand All @@ -32,5 +32,11 @@ async def exists(self, key: RedisKey | str) -> bool:
async def expire(self, key: RedisKey | str, seconds: int):
await self.client.expire(key, seconds)

@classmethod
def get_instance(cls) -> "RedisClient":
if cls._instance is None:
raise RuntimeError("RedisClient not initialized")
return cls._instance

async def close(self):
await self.client.close()
105 changes: 88 additions & 17 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import logging
import time
from contextlib import asynccontextmanager

from fastapi import FastAPI
import asyncio
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint

from app.core.config import settings
from app.infra.minio import init_minio_client
Expand All @@ -9,37 +13,104 @@
from app.router.mobile.auth import router as mobile_auth_router


app = FastAPI()

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)

logger = logging.getLogger("api")

@app.get("/")
def read_root():
return {"Hello": "World"}


@app.get("/health")
def health_check():
return {"status": "healthy"}
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(
self,
request: Request,
call_next: RequestResponseEndpoint,
) -> Response:

start_time = time.time()

response = await call_next(request)

process_time = time.time() - start_time

app.include_router(mobile_auth_router, prefix="/mobile")
logger.info(
"%s %s status=%s time=%.3fs",
request.method,
request.url.path,
response.status_code,
process_time,
)

return response


MAX_RETRIES = 5
RETRY_DELAY = 2 # seconds
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_minio_client(
minio_host=settings.MINIO_HOST,
minio_port=settings.MINIO_API_PORT,
minio_root_user=settings.MINIO_ROOT_USER,
minio_root_password=settings.MINIO_ROOT_PASSWORD,
)

for attempt in range(1, MAX_RETRIES + 1):
try:
await init_minio_client(
minio_host=settings.MINIO_HOST,
minio_port=settings.MINIO_API_PORT,
minio_root_user=settings.MINIO_ROOT_USER,
minio_root_password=settings.MINIO_ROOT_PASSWORD,
)
break
except Exception as e:
print(f"[MINIO] Attempt {attempt} failed: {e}")
if attempt == MAX_RETRIES:
raise RuntimeError("Cannot connect to MinIO after multiple attempts") from e
await asyncio.sleep(RETRY_DELAY)

RedisClient(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
password=settings.REDIS_PASSWORD,
)

await NatsClient.connect()

yield
await RedisClient.close(RedisClient._instance.client)#todo:fix for self

await RedisClient.get_instance().close()
await NatsClient.close()



app = FastAPI(
title="multAI API",
description="Mobile and Web API for multAI",
version="1.0.0",
lifespan=lifespan,
)



app.add_middleware(RequestLoggingMiddleware)

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)



@app.get("/")
def read_root():
return {"Hello": "World"}


@app.get("/health")
def health_check():
return {"status": "healthy"}


app.include_router(mobile_auth_router, prefix="/mobile")
Loading
Loading