refactor: Phase 1 - rename and simplify backend schemas

This commit is contained in:
qzl
2026-02-26 13:33:02 +08:00
parent c6eb58d8da
commit 04726b42cb
6 changed files with 335 additions and 20 deletions
+12 -20
View File
@@ -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):
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+95
View File
@@ -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)
+81
View File
@@ -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
+43
View File
@@ -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
+103
View File
@@ -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,
)