refactor: Phase 1 - rename and simplify backend schemas
This commit is contained in:
@@ -1,36 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class SignupStartRequest(BaseModel):
|
||||
class VerificationCreateRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=30)
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=6)
|
||||
redirect_to: str | None = None
|
||||
|
||||
|
||||
class SignupVerifyRequest(BaseModel):
|
||||
class VerificationResendRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class VerificationVerifyRequest(BaseModel):
|
||||
email: EmailStr
|
||||
token: str = Field(pattern=r"^\d{6}$")
|
||||
|
||||
|
||||
class SignupResendRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
class SessionCreateRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=6)
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
class SessionRefreshRequest(BaseModel):
|
||||
refresh_token: str = Field(min_length=1)
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
class SessionDeleteRequest(BaseModel):
|
||||
refresh_token: str = Field(min_length=1)
|
||||
|
||||
|
||||
@@ -39,7 +37,7 @@ class AuthUser(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class AuthTokenResponse(BaseModel):
|
||||
class SessionResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_in: int
|
||||
@@ -47,21 +45,15 @@ class AuthTokenResponse(BaseModel):
|
||||
user: AuthUser
|
||||
|
||||
|
||||
class AuthUserByEmailResponse(BaseModel):
|
||||
class UserByEmailResponse(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
created_at: str
|
||||
email_confirmed_at: str | None = None
|
||||
|
||||
|
||||
class AuthSignupStartResponse(BaseModel):
|
||||
status: Literal["pending_verification"] = "pending_verification"
|
||||
class VerificationCreateResponse(BaseModel):
|
||||
email: EmailStr
|
||||
message: str = "Verification code sent"
|
||||
|
||||
|
||||
class AuthResendCodeResponse(BaseModel):
|
||||
message: str = "If the email exists, a verification code has been sent"
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,95 @@
|
||||
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.auth.models import CurrentUser
|
||||
from core.config.settings import config
|
||||
from core.db import get_db
|
||||
from core.logging import get_logger
|
||||
from v1.users.repository import SQLAlchemyUserRepository
|
||||
from v1.users.service import UserService
|
||||
|
||||
logger = get_logger("v1.users.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))
|
||||
email = payload.get("email") if isinstance(payload.get("email"), str) else None
|
||||
role = payload.get("role") if isinstance(payload.get("role"), str) else None
|
||||
return CurrentUser(id=user_id, email=email, role=role)
|
||||
|
||||
|
||||
def get_user_service(
|
||||
session: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> UserService:
|
||||
repository = SQLAlchemyUserRepository(session)
|
||||
return UserService(repository=repository, session=session, current_user=user)
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
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.users.repository")
|
||||
|
||||
|
||||
class UserRepository(Protocol):
|
||||
"""Protocol defining the user repository interface."""
|
||||
|
||||
async def get_by_user_id(self, user_id: UUID) -> Profile | None:
|
||||
"""Get user by user ID."""
|
||||
...
|
||||
|
||||
async def get_by_username(self, username: str) -> Profile | None:
|
||||
"""Get user by username."""
|
||||
...
|
||||
|
||||
async def update_by_user_id(
|
||||
self, user_id: UUID, update_data: dict[str, str | None]
|
||||
) -> Profile | None:
|
||||
"""Update user by user ID. Returns updated user or None if not found."""
|
||||
...
|
||||
|
||||
|
||||
class SQLAlchemyUserRepository(BaseRepository[Profile]):
|
||||
"""SQLAlchemy implementation of UserRepository.
|
||||
|
||||
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("User lookup failed", user_id=str(user_id))
|
||||
raise
|
||||
|
||||
async def get_by_username(self, username: str) -> Profile | None:
|
||||
try:
|
||||
stmt = (
|
||||
select(Profile)
|
||||
.where(Profile.username == username)
|
||||
.where(Profile.deleted_at.is_(None))
|
||||
.order_by(Profile.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
except SQLAlchemyError:
|
||||
logger.exception("User 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("User update failed", user_id=str(user_id))
|
||||
raise
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import (
|
||||
AnyHttpUrl,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
avatar_url: str | None = None
|
||||
bio: str | None = None
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
username: str | None = Field(default=None, min_length=3, max_length=30)
|
||||
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) -> "UserUpdateRequest":
|
||||
if self.username 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,103 @@
|
||||
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.users.repository import UserRepository
|
||||
from v1.users.schemas import UserResponse, UserUpdateRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = get_logger("v1.users.service")
|
||||
|
||||
|
||||
class UserService(BaseService):
|
||||
"""User service handling business logic and transactions.
|
||||
|
||||
Responsibilities:
|
||||
- Authorization checks
|
||||
- Transaction boundary (commit/rollback)
|
||||
- Converting ORM models to response schemas
|
||||
"""
|
||||
|
||||
_repository: UserRepository
|
||||
_session: AsyncSession
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: UserRepository,
|
||||
session: AsyncSession,
|
||||
current_user: CurrentUser | None,
|
||||
) -> None:
|
||||
super().__init__(current_user=current_user)
|
||||
self._repository = repository
|
||||
self._session = session
|
||||
|
||||
async def get_me(self) -> UserResponse:
|
||||
user_id = self.require_user_id()
|
||||
try:
|
||||
user = await self._repository.get_by_user_id(user_id)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="User store unavailable")
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return UserResponse(
|
||||
id=str(user.id),
|
||||
username=user.username,
|
||||
avatar_url=user.avatar_url,
|
||||
bio=user.bio,
|
||||
)
|
||||
|
||||
async def update_me(self, update: UserUpdateRequest) -> UserResponse:
|
||||
user_id = self.require_user_id()
|
||||
update_data: dict[str, str | None] = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"username": update.username,
|
||||
"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:
|
||||
user = 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="User store unavailable")
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return UserResponse(
|
||||
id=str(user.id),
|
||||
username=user.username,
|
||||
avatar_url=user.avatar_url,
|
||||
bio=user.bio,
|
||||
)
|
||||
|
||||
async def get_by_username(self, username: str) -> UserResponse:
|
||||
try:
|
||||
user = await self._repository.get_by_username(username)
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(status_code=503, detail="User store unavailable")
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return UserResponse(
|
||||
id=str(user.id),
|
||||
username=user.username,
|
||||
avatar_url=user.avatar_url,
|
||||
bio=user.bio,
|
||||
)
|
||||
Reference in New Issue
Block a user