Init project structure
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP API routers and dependencies."""
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Reusable FastAPI dependencies."""
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.core.security import jwt_service, password_hasher
|
||||
from app.repositories.user_repo import UserRepository
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.user_service import UserService
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Provide a scoped database session."""
|
||||
async for session in get_session():
|
||||
yield session
|
||||
|
||||
|
||||
def get_user_repository(session: AsyncSession = Depends(get_db_session)) -> UserRepository:
|
||||
return UserRepository(session=session)
|
||||
|
||||
|
||||
def get_user_service(repo: UserRepository = Depends(get_user_repository)) -> UserService:
|
||||
return UserService(user_repository=repo, password_hasher=password_hasher)
|
||||
|
||||
|
||||
def get_auth_service(
|
||||
repo: UserRepository = Depends(get_user_repository),
|
||||
) -> AuthService:
|
||||
return AuthService(
|
||||
user_repository=repo,
|
||||
password_hasher=password_hasher,
|
||||
jwt_service=jwt_service,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Root API router that aggregates versioned routers."""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users
|
||||
from app.core.config import settings
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(users.router, prefix=settings.api_v1_prefix)
|
||||
api_router.include_router(auth.router, prefix=settings.api_v1_prefix)
|
||||
@@ -0,0 +1 @@
|
||||
"""Version 1 API routers."""
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Authentication API endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.api.deps import get_auth_service
|
||||
from app.models.token import LoginRequest, TokenResponse
|
||||
from app.services.auth_service import AuthService, InvalidCredentialsError
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/token", response_model=TokenResponse)
|
||||
async def login_for_access_token(
|
||||
credentials: LoginRequest,
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
) -> TokenResponse:
|
||||
try:
|
||||
user = await service.authenticate(credentials.email, credentials.password)
|
||||
except InvalidCredentialsError as exc: # pragma: no cover - thin API
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||
return service.create_access_token(user)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""User API endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.api.deps import get_user_service
|
||||
from app.models.user import UserCreate, UserRead
|
||||
from app.services.user_service import UserAlreadyExistsError, UserNotFoundError, UserService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[UserRead])
|
||||
async def list_users(service: UserService = Depends(get_user_service)) -> list[UserRead]:
|
||||
users = await service.list_users()
|
||||
return [UserRead.model_validate(user) for user in users]
|
||||
|
||||
|
||||
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
user_in: UserCreate,
|
||||
service: UserService = Depends(get_user_service),
|
||||
) -> UserRead:
|
||||
try:
|
||||
user = await service.create_user(user_in)
|
||||
except UserAlreadyExistsError as exc: # pragma: no cover - thin API layer
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
return UserRead.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserRead)
|
||||
async def get_user(user_id: int, service: UserService = Depends(get_user_service)) -> UserRead:
|
||||
try:
|
||||
user = await service.get_user(user_id)
|
||||
except UserNotFoundError as exc: # pragma: no cover - thin API layer
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
return UserRead.model_validate(user)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Application settings using Pydantic Settings."""
|
||||
from pydantic import Field, SecretStr
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Runtime application configuration."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="allow")
|
||||
|
||||
project_name: str = "Test Task CRM"
|
||||
version: str = "0.1.0"
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://postgres:postgres@localhost:5432/test_task_crm",
|
||||
description="SQLAlchemy async connection string",
|
||||
)
|
||||
sqlalchemy_echo: bool = False
|
||||
jwt_secret_key: SecretStr = Field(default=SecretStr("change-me"))
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 30
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Database utilities for async SQLAlchemy engine and sessions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=settings.sqlalchemy_echo)
|
||||
AsyncSessionMaker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Yield an async database session for request scope."""
|
||||
async with AsyncSessionMaker() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Security helpers for hashing passwords and issuing JWT tokens."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Mapping
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class PasswordHasher:
|
||||
"""Wraps passlib context to hash and verify secrets."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def hash(self, password: str) -> str:
|
||||
return self._context.hash(password)
|
||||
|
||||
def verify(self, password: str, hashed_password: str) -> bool:
|
||||
return self._context.verify(password, hashed_password)
|
||||
|
||||
|
||||
class JWTService:
|
||||
"""Handles encoding and decoding of JWT access tokens."""
|
||||
|
||||
def __init__(self, secret_key: str, algorithm: str) -> None:
|
||||
self._secret_key = secret_key
|
||||
self._algorithm = algorithm
|
||||
|
||||
def create_access_token(
|
||||
self,
|
||||
subject: str,
|
||||
expires_delta: timedelta,
|
||||
claims: Mapping[str, Any] | None = None,
|
||||
) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": subject,
|
||||
"iat": now,
|
||||
"exp": now + expires_delta,
|
||||
}
|
||||
if claims:
|
||||
payload.update(claims)
|
||||
return jwt.encode(payload, self._secret_key, algorithm=self._algorithm)
|
||||
|
||||
def decode(self, token: str) -> dict[str, Any]:
|
||||
return jwt.decode(token, self._secret_key, algorithms=[self._algorithm])
|
||||
|
||||
|
||||
password_hasher = PasswordHasher()
|
||||
jwt_service = JWTService(
|
||||
secret_key=settings.jwt_secret_key.get_secret_value(),
|
||||
algorithm=settings.jwt_algorithm,
|
||||
)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
"""FastAPI application factory."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.routes import api_router
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Build FastAPI application instance."""
|
||||
application = FastAPI(title=settings.project_name, version=settings.version)
|
||||
application.include_router(api_router)
|
||||
return application
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Model exports for Alembic discovery."""
|
||||
from app.models.base import Base
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = ["Base", "User"]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Declarative base for SQLAlchemy models."""
|
||||
from sqlalchemy.orm import DeclarativeBase, declared_attr
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class that configures naming conventions."""
|
||||
|
||||
@declared_attr.directive
|
||||
def __tablename__(cls) -> str: # type: ignore[misc]
|
||||
return cls.__name__.lower()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Token-related Pydantic schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: str
|
||||
exp: datetime
|
||||
email: EmailStr | None = None
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
@@ -0,0 +1,48 @@
|
||||
"""User ORM model and Pydantic schemas."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""SQLAlchemy model for application users."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Shared user fields for Pydantic schemas."""
|
||||
|
||||
email: EmailStr
|
||||
full_name: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
|
||||
class UserRead(UserBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""Repository layer."""
|
||||
@@ -0,0 +1,43 @@
|
||||
"""User repository handling database operations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User, UserCreate
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""Provides CRUD helpers for User model."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
@property
|
||||
def session(self) -> AsyncSession:
|
||||
return self._session
|
||||
|
||||
async def list(self) -> Sequence[User]:
|
||||
result = await self._session.scalars(select(User))
|
||||
return result.all()
|
||||
|
||||
async def get_by_id(self, user_id: int) -> User | None:
|
||||
return await self._session.get(User, user_id)
|
||||
|
||||
async def get_by_email(self, email: str) -> User | None:
|
||||
stmt = select(User).where(User.email == email)
|
||||
result = await self._session.scalars(stmt)
|
||||
return result.first()
|
||||
|
||||
async def create(self, data: UserCreate, hashed_password: str) -> User:
|
||||
user = User(
|
||||
email=data.email,
|
||||
hashed_password=hashed_password,
|
||||
full_name=data.full_name,
|
||||
is_active=data.is_active,
|
||||
)
|
||||
self._session.add(user)
|
||||
await self._session.flush()
|
||||
return user
|
||||
@@ -0,0 +1 @@
|
||||
"""Business logic services."""
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Authentication workflows."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import JWTService, PasswordHasher
|
||||
from app.models.token import TokenResponse
|
||||
from app.models.user import User
|
||||
from app.repositories.user_repo import UserRepository
|
||||
|
||||
|
||||
class InvalidCredentialsError(Exception):
|
||||
"""Raised when user authentication fails."""
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Handles authentication flows and token issuance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_repository: UserRepository,
|
||||
password_hasher: PasswordHasher,
|
||||
jwt_service: JWTService,
|
||||
) -> None:
|
||||
self._user_repository = user_repository
|
||||
self._password_hasher = password_hasher
|
||||
self._jwt_service = jwt_service
|
||||
|
||||
async def authenticate(self, email: str, password: str) -> User:
|
||||
user = await self._user_repository.get_by_email(email)
|
||||
if user is None or not self._password_hasher.verify(password, user.hashed_password):
|
||||
raise InvalidCredentialsError("Invalid email or password")
|
||||
return user
|
||||
|
||||
def create_access_token(self, user: User) -> TokenResponse:
|
||||
expires_delta = timedelta(minutes=settings.access_token_expire_minutes)
|
||||
token = self._jwt_service.create_access_token(
|
||||
subject=str(user.id),
|
||||
expires_delta=expires_delta,
|
||||
claims={"email": user.email},
|
||||
)
|
||||
return TokenResponse(access_token=token, expires_in=int(expires_delta.total_seconds()))
|
||||
@@ -0,0 +1,48 @@
|
||||
"""User-related business logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.core.security import PasswordHasher
|
||||
from app.models.user import User, UserCreate
|
||||
from app.repositories.user_repo import UserRepository
|
||||
|
||||
|
||||
class UserServiceError(Exception):
|
||||
"""Base class for user service errors."""
|
||||
|
||||
|
||||
class UserAlreadyExistsError(UserServiceError):
|
||||
"""Raised when attempting to create a user with duplicate email."""
|
||||
|
||||
|
||||
class UserNotFoundError(UserServiceError):
|
||||
"""Raised when user record cannot be located."""
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Encapsulates user-related workflows."""
|
||||
|
||||
def __init__(self, user_repository: UserRepository, password_hasher: PasswordHasher) -> None:
|
||||
self._repository = user_repository
|
||||
self._password_hasher = password_hasher
|
||||
|
||||
async def list_users(self) -> Sequence[User]:
|
||||
return await self._repository.list()
|
||||
|
||||
async def get_user(self, user_id: int) -> User:
|
||||
user = await self._repository.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise UserNotFoundError(f"User {user_id} not found")
|
||||
return user
|
||||
|
||||
async def create_user(self, data: UserCreate) -> User:
|
||||
existing = await self._repository.get_by_email(data.email)
|
||||
if existing is not None:
|
||||
raise UserAlreadyExistsError(f"User {data.email} already exists")
|
||||
|
||||
hashed_password = self._password_hasher.hash(data.password)
|
||||
user = await self._repository.create(data=data, hashed_password=hashed_password)
|
||||
await self._repository.session.commit()
|
||||
await self._repository.session.refresh(user)
|
||||
return user
|
||||
Reference in New Issue
Block a user