refactor: Phase 1 - rename and simplify backend schemas
This commit is contained in:
@@ -1,36 +1,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
class SignupStartRequest(BaseModel):
|
class VerificationCreateRequest(BaseModel):
|
||||||
username: str = Field(min_length=3, max_length=30)
|
username: str = Field(min_length=3, max_length=30)
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str = Field(min_length=6)
|
password: str = Field(min_length=6)
|
||||||
redirect_to: str | None = None
|
redirect_to: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SignupVerifyRequest(BaseModel):
|
class VerificationResendRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationVerifyRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
token: str = Field(pattern=r"^\d{6}$")
|
token: str = Field(pattern=r"^\d{6}$")
|
||||||
|
|
||||||
|
|
||||||
class SignupResendRequest(BaseModel):
|
class SessionCreateRequest(BaseModel):
|
||||||
email: EmailStr
|
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str = Field(min_length=6)
|
password: str = Field(min_length=6)
|
||||||
|
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
class SessionRefreshRequest(BaseModel):
|
||||||
refresh_token: str = Field(min_length=1)
|
refresh_token: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class LogoutRequest(BaseModel):
|
class SessionDeleteRequest(BaseModel):
|
||||||
refresh_token: str = Field(min_length=1)
|
refresh_token: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +37,7 @@ class AuthUser(BaseModel):
|
|||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
class AuthTokenResponse(BaseModel):
|
class SessionResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
expires_in: int
|
expires_in: int
|
||||||
@@ -47,21 +45,15 @@ class AuthTokenResponse(BaseModel):
|
|||||||
user: AuthUser
|
user: AuthUser
|
||||||
|
|
||||||
|
|
||||||
class AuthUserByEmailResponse(BaseModel):
|
class UserByEmailResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
created_at: str
|
created_at: str
|
||||||
email_confirmed_at: str | None = None
|
email_confirmed_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuthSignupStartResponse(BaseModel):
|
class VerificationCreateResponse(BaseModel):
|
||||||
status: Literal["pending_verification"] = "pending_verification"
|
|
||||||
email: EmailStr
|
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):
|
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