refactor: align backend layout and supabase infra
Consolidate backend modules/tests under the backend package while syncing Supabase compose/env config and related plans.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from v1.auth.service import AuthService, SupabaseAuthGateway
|
||||
|
||||
|
||||
def get_auth_service() -> AuthService:
|
||||
return AuthService(gateway=SupabaseAuthGateway())
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=6)
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=6)
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str = Field(min_length=1)
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AuthUser(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class AuthTokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
token_type: str
|
||||
user: AuthUser
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from v1.auth.dependencies import get_auth_service
|
||||
from v1.auth.models import (
|
||||
AuthTokenResponse,
|
||||
LoginRequest,
|
||||
LogoutRequest,
|
||||
RefreshRequest,
|
||||
SignupRequest,
|
||||
)
|
||||
from v1.auth.service import AuthService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/signup", response_model=AuthTokenResponse)
|
||||
async def signup(
|
||||
payload: SignupRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> AuthTokenResponse:
|
||||
return await service.signup(payload)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthTokenResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> AuthTokenResponse:
|
||||
return await service.login(payload)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=AuthTokenResponse)
|
||||
async def refresh(
|
||||
payload: RefreshRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> AuthTokenResponse:
|
||||
return await service.refresh(payload)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
async def logout(
|
||||
payload: LogoutRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> Response:
|
||||
await service.logout(payload.refresh_token)
|
||||
return Response(status_code=204)
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from fastapi import HTTPException
|
||||
from supabase import AuthError, create_client
|
||||
|
||||
from core.config.settings import SupabaseSettings, config
|
||||
from core.logging import get_logger
|
||||
from v1.auth.models import (
|
||||
AuthTokenResponse,
|
||||
AuthUser,
|
||||
LoginRequest,
|
||||
RefreshRequest,
|
||||
SignupRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = get_logger("v1.auth.service")
|
||||
|
||||
|
||||
class AuthServiceGateway(Protocol):
|
||||
async def signup(self, request: SignupRequest) -> AuthTokenResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
async def login(self, request: LoginRequest) -> AuthTokenResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
async def refresh(self, request: RefreshRequest) -> AuthTokenResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
async def logout(self, refresh_token: str | None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SupabaseAuthGateway(AuthServiceGateway):
|
||||
_client: Any
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings: SupabaseSettings = config.supabase
|
||||
self._client = create_client(settings.url, settings.anon_key)
|
||||
|
||||
async def signup(self, request: SignupRequest) -> AuthTokenResponse:
|
||||
payload: dict[str, Any] = {
|
||||
"email": request.email,
|
||||
"password": request.password,
|
||||
}
|
||||
if request.display_name:
|
||||
payload = {
|
||||
**payload,
|
||||
"data": {"display_name": request.display_name},
|
||||
}
|
||||
try:
|
||||
sign_up = cast(Any, self._client.auth.sign_up)
|
||||
response = await asyncio.to_thread(sign_up, payload)
|
||||
return _map_auth_response(response, "Authentication failed")
|
||||
except AuthError as exc:
|
||||
logger.warning("Signup failed", error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Authentication failed"
|
||||
) from exc
|
||||
|
||||
async def login(self, request: LoginRequest) -> AuthTokenResponse:
|
||||
payload: dict[str, Any] = {"email": request.email, "password": request.password}
|
||||
try:
|
||||
sign_in = cast(Any, self._client.auth.sign_in_with_password)
|
||||
response = await asyncio.to_thread(sign_in, payload)
|
||||
return _map_auth_response(response, "Invalid credentials")
|
||||
except AuthError as exc:
|
||||
logger.warning("Login failed", error=str(exc))
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials") from exc
|
||||
|
||||
async def refresh(self, request: RefreshRequest) -> AuthTokenResponse:
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
self._client.auth.refresh_session,
|
||||
request.refresh_token,
|
||||
)
|
||||
return _map_auth_response(response, "Invalid refresh token")
|
||||
except AuthError as exc:
|
||||
logger.warning("Refresh failed", error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid refresh token"
|
||||
) from exc
|
||||
|
||||
async def logout(self, refresh_token: str | None) -> None:
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=401, detail="Missing refresh token")
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
self._client.auth.refresh_session,
|
||||
refresh_token,
|
||||
)
|
||||
session = getattr(response, "session", None)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
await asyncio.to_thread(
|
||||
self._client.auth.set_session,
|
||||
str(session.access_token),
|
||||
str(session.refresh_token),
|
||||
)
|
||||
await asyncio.to_thread(self._client.auth.sign_out)
|
||||
except AuthError as exc:
|
||||
logger.warning("Logout failed", error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid refresh token"
|
||||
) from exc
|
||||
|
||||
|
||||
class AuthService:
|
||||
_gateway: AuthServiceGateway
|
||||
|
||||
def __init__(self, gateway: AuthServiceGateway) -> None:
|
||||
self._gateway = gateway
|
||||
|
||||
async def signup(self, request: SignupRequest) -> AuthTokenResponse:
|
||||
return await self._gateway.signup(request)
|
||||
|
||||
async def login(self, request: LoginRequest) -> AuthTokenResponse:
|
||||
return await self._gateway.login(request)
|
||||
|
||||
async def refresh(self, request: RefreshRequest) -> AuthTokenResponse:
|
||||
return await self._gateway.refresh(request)
|
||||
|
||||
async def logout(self, refresh_token: str | None) -> None:
|
||||
await self._gateway.logout(refresh_token)
|
||||
|
||||
|
||||
def _map_auth_response(response: object, failure_message: str) -> AuthTokenResponse:
|
||||
session = getattr(response, "session", None)
|
||||
user = getattr(response, "user", None)
|
||||
if session is None or user is None:
|
||||
raise HTTPException(status_code=401, detail=failure_message)
|
||||
|
||||
email = getattr(user, "email", None)
|
||||
if not email:
|
||||
raise HTTPException(status_code=401, detail=failure_message)
|
||||
|
||||
auth_user = AuthUser(id=str(user.id), email=str(email))
|
||||
return AuthTokenResponse(
|
||||
access_token=str(session.access_token),
|
||||
refresh_token=str(session.refresh_token),
|
||||
expires_in=int(session.expires_in or 0),
|
||||
token_type=str(session.token_type),
|
||||
user=auth_user,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from services.base.redis import RedisService, redis_service
|
||||
from services.base.qdrant import QdrantService, qdrant_service
|
||||
|
||||
|
||||
def get_redis_service() -> RedisService:
|
||||
return redis_service
|
||||
|
||||
|
||||
def get_qdrant_service() -> QdrantService:
|
||||
return qdrant_service
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from services.base.qdrant import QdrantService
|
||||
from services.base.redis import RedisService
|
||||
from v1.infra.dependencies import get_qdrant_service, get_redis_service
|
||||
from v1.infra.schemas import InfraHealthResponse, ServiceHealth
|
||||
|
||||
|
||||
router = APIRouter(prefix="/infra", tags=["infra"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=InfraHealthResponse)
|
||||
async def infra_health(
|
||||
redis_service: RedisService = Depends(get_redis_service),
|
||||
qdrant_service: QdrantService = Depends(get_qdrant_service),
|
||||
) -> InfraHealthResponse:
|
||||
if not redis_service.is_initialized:
|
||||
await redis_service.initialize()
|
||||
if not qdrant_service.is_initialized:
|
||||
await qdrant_service.initialize()
|
||||
|
||||
redis_health = await redis_service.health_check()
|
||||
qdrant_health = await qdrant_service.health_check()
|
||||
status = (
|
||||
"healthy"
|
||||
if redis_health["status"] == "healthy" and qdrant_health["status"] == "healthy"
|
||||
else "unhealthy"
|
||||
)
|
||||
|
||||
return InfraHealthResponse(
|
||||
status=status,
|
||||
services={
|
||||
"redis": ServiceHealth(**redis_health),
|
||||
"qdrant": ServiceHealth(**qdrant_health),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ServiceHealth(BaseModel):
|
||||
status: Literal["healthy", "unhealthy"]
|
||||
details: Dict[str, Any]
|
||||
|
||||
|
||||
class InfraHealthResponse(BaseModel):
|
||||
status: Literal["healthy", "unhealthy"]
|
||||
services: Dict[str, ServiceHealth]
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config.settings import config
|
||||
from core.db import get_db
|
||||
from core.logging import get_logger
|
||||
from core.auth.models import CurrentUser
|
||||
from v1.profile.repository import SQLAlchemyProfileRepository
|
||||
from v1.profile.service import ProfileService
|
||||
|
||||
logger = get_logger("v1.profile.dependencies")
|
||||
|
||||
|
||||
def get_current_user(authorization: str | None = Header(default=None)) -> CurrentUser:
|
||||
if not authorization:
|
||||
logger.warning("JWT validation failed: missing authorization header")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer" or not token:
|
||||
logger.warning("JWT validation failed: invalid authorization scheme")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
secret = config.supabase.jwt_secret
|
||||
if not secret:
|
||||
logger.error("JWT validation failed: secret not configured")
|
||||
raise HTTPException(status_code=503, detail="JWT secret not configured")
|
||||
|
||||
supabase_url = config.supabase.public_url.rstrip("/")
|
||||
expected_issuer = f"{supabase_url}/auth/v1"
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
secret,
|
||||
algorithms=["HS256"],
|
||||
audience="authenticated",
|
||||
issuer=expected_issuer,
|
||||
options={
|
||||
"verify_aud": True,
|
||||
"verify_iss": True,
|
||||
"verify_exp": True,
|
||||
"require": ["sub", "aud", "iss", "exp"],
|
||||
},
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("JWT validation failed: token expired")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
except jwt.InvalidAudienceError:
|
||||
logger.warning("JWT validation failed: invalid audience")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
except jwt.InvalidIssuerError:
|
||||
logger.warning("JWT validation failed: invalid issuer")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
except jwt.InvalidSignatureError:
|
||||
logger.warning("JWT validation failed: invalid signature")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
except jwt.DecodeError:
|
||||
logger.warning("JWT validation failed: malformed token")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
except jwt.PyJWTError as exc:
|
||||
logger.warning(
|
||||
"JWT validation failed: unknown error", error_type=type(exc).__name__
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Unauthorized") from exc
|
||||
|
||||
subject = payload.get("sub")
|
||||
if not isinstance(subject, str) or not subject:
|
||||
logger.warning("JWT validation failed: missing or invalid subject claim")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
try:
|
||||
user_id = UUID(subject)
|
||||
except ValueError:
|
||||
logger.warning("JWT validation failed: invalid UUID in subject")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
logger.debug("JWT validation successful", user_id=str(user_id))
|
||||
return CurrentUser(id=user_id)
|
||||
|
||||
|
||||
def get_profile_service(
|
||||
session: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> ProfileService:
|
||||
repository = SQLAlchemyProfileRepository(session)
|
||||
return ProfileService(repository=repository, session=session, current_user=user)
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.db.base_repository import BaseRepository
|
||||
from core.logging import get_logger
|
||||
from models.profile import Profile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = get_logger("v1.profile.repository")
|
||||
|
||||
|
||||
class ProfileRepository(Protocol):
|
||||
"""Protocol defining the profile repository interface."""
|
||||
|
||||
async def get_by_user_id(self, user_id: UUID) -> Profile | None:
|
||||
"""Get profile by user ID."""
|
||||
...
|
||||
|
||||
async def get_by_username(self, username: str) -> Profile | None:
|
||||
"""Get profile by username."""
|
||||
...
|
||||
|
||||
async def update_by_user_id(
|
||||
self, user_id: UUID, update_data: dict[str, str | None]
|
||||
) -> Profile | None:
|
||||
"""Update profile by user ID. Returns updated profile or None if not found."""
|
||||
...
|
||||
|
||||
|
||||
class SQLAlchemyProfileRepository(BaseRepository[Profile]):
|
||||
"""SQLAlchemy implementation of ProfileRepository.
|
||||
|
||||
Note: This repository only performs CRUD operations.
|
||||
- No commit (only flush) - service layer handles transactions
|
||||
- No auth logic - service layer handles authorization
|
||||
- No HTTP exceptions - returns None or raises SQLAlchemyError
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
super().__init__(session, Profile)
|
||||
|
||||
async def get_by_user_id(self, user_id: UUID) -> Profile | None:
|
||||
try:
|
||||
return await self.get_by_id(user_id)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("Profile lookup failed", user_id=str(user_id))
|
||||
raise
|
||||
|
||||
async def get_by_username(self, username: str) -> Profile | None:
|
||||
try:
|
||||
return await self.get_one(Profile.username == username)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("Profile lookup failed", username=username)
|
||||
raise
|
||||
|
||||
async def update_by_user_id(
|
||||
self, user_id: UUID, update_data: dict[str, str | None]
|
||||
) -> Profile | None:
|
||||
if not update_data:
|
||||
return await self.get_by_user_id(user_id)
|
||||
|
||||
try:
|
||||
return await self.update_by_id(user_id, update_data)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("Profile update failed", user_id=str(user_id))
|
||||
raise
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path
|
||||
|
||||
from v1.profile.dependencies import get_profile_service
|
||||
from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest
|
||||
from v1.profile.service import ProfileService
|
||||
|
||||
router = APIRouter(prefix="/profile", tags=["profile"])
|
||||
|
||||
|
||||
@router.get("/me", response_model=ProfileResponse)
|
||||
async def get_me(
|
||||
service: Annotated[ProfileService, Depends(get_profile_service)],
|
||||
) -> ProfileResponse:
|
||||
return await service.get_me()
|
||||
|
||||
|
||||
@router.patch("/me", response_model=ProfileResponse)
|
||||
async def update_me(
|
||||
payload: ProfileUpdateRequest,
|
||||
service: Annotated[ProfileService, Depends(get_profile_service)],
|
||||
) -> ProfileResponse:
|
||||
return await service.update_me(payload)
|
||||
|
||||
|
||||
@router.get("/{username}", response_model=ProfileResponse)
|
||||
async def get_by_username(
|
||||
username: Annotated[
|
||||
str, Path(min_length=3, max_length=30, pattern="^[a-zA-Z0-9_]+$")
|
||||
],
|
||||
service: Annotated[ProfileService, Depends(get_profile_service)],
|
||||
) -> ProfileResponse:
|
||||
return await service.get_by_username(username)
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import AnyHttpUrl, BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
display_name: str | None = None
|
||||
avatar_url: str | None = None
|
||||
bio: str | None = None
|
||||
|
||||
|
||||
class ProfileUpdateRequest(BaseModel):
|
||||
display_name: str | None = Field(default=None, max_length=50)
|
||||
avatar_url: str | None = Field(default=None)
|
||||
bio: str | None = Field(default=None, max_length=200)
|
||||
|
||||
@field_validator("avatar_url", mode="before")
|
||||
@classmethod
|
||||
def validate_avatar_url(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
parsed = AnyHttpUrl(v)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("avatar_url must use http or https scheme")
|
||||
return str(parsed)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_one_field(self) -> "ProfileUpdateRequest":
|
||||
if self.display_name is None and self.avatar_url is None and self.bio is None:
|
||||
raise ValueError("At least one field must be provided")
|
||||
return self
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from core.db.base_service import BaseService
|
||||
from core.logging import get_logger
|
||||
from v1.profile.repository import ProfileRepository
|
||||
from v1.profile.schemas import ProfileResponse, ProfileUpdateRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = get_logger("v1.profile.service")
|
||||
|
||||
|
||||
class ProfileService(BaseService):
|
||||
"""Profile service handling business logic and transactions.
|
||||
|
||||
Responsibilities:
|
||||
- Authorization checks
|
||||
- Transaction boundary (commit/rollback)
|
||||
- Converting ORM models to response schemas
|
||||
"""
|
||||
|
||||
_repository: ProfileRepository
|
||||
_session: AsyncSession
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: ProfileRepository,
|
||||
session: AsyncSession,
|
||||
current_user: CurrentUser | None,
|
||||
) -> None:
|
||||
super().__init__(current_user=current_user)
|
||||
self._repository = repository
|
||||
self._session = session
|
||||
|
||||
async def get_me(self) -> ProfileResponse:
|
||||
user_id = self.require_user_id()
|
||||
try:
|
||||
profile = await self._repository.get_by_user_id(user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Profile store unavailable")
|
||||
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
return ProfileResponse(
|
||||
id=str(profile.id),
|
||||
username=profile.username,
|
||||
display_name=profile.display_name,
|
||||
avatar_url=profile.avatar_url,
|
||||
bio=profile.bio,
|
||||
)
|
||||
|
||||
async def update_me(self, update: ProfileUpdateRequest) -> ProfileResponse:
|
||||
user_id = self.require_user_id()
|
||||
update_data: dict[str, str | None] = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"display_name": update.display_name,
|
||||
"avatar_url": update.avatar_url,
|
||||
"bio": update.bio,
|
||||
}.items()
|
||||
if value is not None
|
||||
}
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
try:
|
||||
profile = await self._repository.update_by_user_id(user_id, update_data)
|
||||
await self._session.commit()
|
||||
except SQLAlchemyError:
|
||||
await self._session.rollback()
|
||||
raise HTTPException(status_code=503, detail="Profile store unavailable")
|
||||
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
|
||||
return ProfileResponse(
|
||||
id=str(profile.id),
|
||||
username=profile.username,
|
||||
display_name=profile.display_name,
|
||||
avatar_url=profile.avatar_url,
|
||||
bio=profile.bio,
|
||||
)
|
||||
|
||||
async def get_by_username(self, username: str) -> ProfileResponse:
|
||||
try:
|
||||
profile = await self._repository.get_by_username(username)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="Profile store unavailable")
|
||||
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
return ProfileResponse(
|
||||
id=str(profile.id),
|
||||
username=profile.username,
|
||||
display_name=profile.display_name,
|
||||
avatar_url=profile.avatar_url,
|
||||
bio=profile.bio,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from core.http.models import HealthResponse
|
||||
from v1.auth.router import router as auth_router
|
||||
from v1.infra.router import router as infra_router
|
||||
from v1.profile.router import router as profile_router
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
router.include_router(auth_router)
|
||||
router.include_router(infra_router)
|
||||
router.include_router(profile_router)
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
return HealthResponse(status="ok")
|
||||
Reference in New Issue
Block a user