Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@

35 changes: 35 additions & 0 deletions app/controllers/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from fastapi import HTTPException, status
from sqlalchemy.orm import Session

from app.exceptions import AuthenticationError, ConflictError, ForbiddenError
from app.models.user import User
from app.schemas.auth import LoginRequest, TokenResponse, UserRegisterRequest
from app.services import auth_service


def register(payload: UserRegisterRequest, db: Session) -> User:
try:
return auth_service.register_user(
db, email=str(payload.email), password=payload.password,
)
except ConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=exc.detail,
) from exc


def login(payload: LoginRequest, db: Session) -> TokenResponse:
try:
return auth_service.authenticate_user(
db, email=str(payload.email), password=payload.password,
)
except AuthenticationError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=exc.detail,
headers={"WWW-Authenticate": "Bearer"},
) from exc
except ForbiddenError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=exc.detail,
) from exc
60 changes: 60 additions & 0 deletions app/controllers/orders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import uuid

from fastapi import HTTPException, status
from sqlalchemy.orm import Session

from app.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError
from app.models.order import Order
from app.models.user import User
from app.schemas.order import OrderCreate, OrderUpdate
from app.services import order_service


def _map_service_exceptions(exc: Exception) -> HTTPException:
if isinstance(exc, NotFoundError):
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=exc.detail)
if isinstance(exc, ForbiddenError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=exc.detail)
if isinstance(exc, ConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=exc.detail)
if isinstance(exc, BadRequestError):
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=exc.detail)
return HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unexpected error")


def create_order(payload: OrderCreate, current_user: User, db: Session) -> Order:
items = [(item.product_id, item.quantity) for item in payload.items]
try:
return order_service.create_order(db, user_id=current_user.id, items=items)
except (NotFoundError, BadRequestError, ConflictError) as exc:
raise _map_service_exceptions(exc) from exc


def list_my_orders(current_user: User, db: Session) -> list[Order]:
return order_service.list_user_orders(db, user_id=current_user.id)


def get_order(order_id: uuid.UUID, current_user: User, db: Session) -> Order:
try:
return order_service.get_user_order(db, order_id=order_id, user_id=current_user.id)
except (NotFoundError, ForbiddenError) as exc:
raise _map_service_exceptions(exc) from exc


def update_order(
order_id: uuid.UUID, payload: OrderUpdate, current_user: User, db: Session,
) -> Order:
items = [(item.product_id, item.quantity) for item in payload.items]
try:
return order_service.update_order(
db, order_id=order_id, user_id=current_user.id, items=items,
)
except (NotFoundError, ForbiddenError, ConflictError, BadRequestError) as exc:
raise _map_service_exceptions(exc) from exc


def cancel_order(order_id: uuid.UUID, current_user: User, db: Session) -> Order:
try:
return order_service.cancel_order(db, order_id=order_id, user_id=current_user.id)
except (NotFoundError, ForbiddenError, ConflictError) as exc:
raise _map_service_exceptions(exc) from exc
8 changes: 8 additions & 0 deletions app/controllers/products.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from sqlalchemy.orm import Session

from app.models.product import Product
from app.services import product_service


def list_products(db: Session) -> list[Product]:
return product_service.list_active_products(db)
9 changes: 9 additions & 0 deletions app/core/security.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from uuid import UUID

import bcrypt
Expand Down Expand Up @@ -38,3 +39,11 @@ def create_access_token(
settings.SECRET_KEY,
algorithm=settings.ALGORITHM,
)


def decode_access_token(token: str) -> dict[str, Any]:
return jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
54 changes: 54 additions & 0 deletions app/deps.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
from collections.abc import Generator
from typing import Annotated
from uuid import UUID

import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.core.database import SessionLocal
from app.core.security import decode_access_token
from app.models.user import User

http_bearer = HTTPBearer(auto_error=False)


def get_db() -> Generator[Session, None, None]:
Expand All @@ -11,3 +21,47 @@ def get_db() -> Generator[Session, None, None]:
yield db
finally:
db.close()


def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(http_bearer)],
db: Session = Depends(get_db),
) -> User:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(credentials.credentials)
sub = payload.get("sub")
if sub is None:
raise jwt.InvalidTokenError()
user_id = UUID(sub)
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
) from None
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token subject",
headers={"WWW-Authenticate": "Bearer"},
) from None

user = db.scalars(select(User).where(User.id == user_id)).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or token revoked",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
return user


CurrentUserDep = Annotated[User, Depends(get_current_user)]
41 changes: 41 additions & 0 deletions app/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
class AppException(Exception):
"""Base domain exception. All service-layer errors inherit from this."""

def __init__(self, detail: str = "An application error occurred") -> None:
self.detail = detail
super().__init__(detail)


class NotFoundError(AppException):
"""Raised when a requested resource does not exist."""

def __init__(self, detail: str = "Resource not found") -> None:
super().__init__(detail)


class ConflictError(AppException):
"""Raised on duplicate resources, insufficient stock, or invalid state transitions."""

def __init__(self, detail: str = "Conflict") -> None:
super().__init__(detail)


class BadRequestError(AppException):
"""Raised when the request is semantically invalid (e.g. duplicate product IDs)."""

def __init__(self, detail: str = "Bad request") -> None:
super().__init__(detail)


class ForbiddenError(AppException):
"""Raised when the user lacks permission (e.g. accessing another user's order)."""

def __init__(self, detail: str = "Forbidden") -> None:
super().__init__(detail)


class AuthenticationError(AppException):
"""Raised when credentials are invalid."""

def __init__(self, detail: str = "Incorrect email or password") -> None:
super().__init__(detail)
4 changes: 4 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
from app.core.config import settings
from app.routes.auth import router as auth_router
from app.routes.health import router as health_router
from app.routes.orders import router as orders_router
from app.routes.products import router as products_router

app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
)

app.include_router(auth_router, prefix="/auth", tags=["auth"])
app.include_router(products_router, prefix="/products", tags=["products"])
app.include_router(orders_router, prefix="/orders", tags=["orders"])
app.include_router(health_router, prefix="/api", tags=["health"])


Expand Down
52 changes: 7 additions & 45 deletions app/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session

from app.core.security import create_access_token, hash_password, verify_password
from app.controllers import auth as ctrl
from app.deps import get_db
from app.models.user import User
from app.schemas.auth import LoginRequest, TokenResponse, UserPublic, UserRegisterRequest

router = APIRouter()


def _normalize_email(email: str) -> str:
return email.strip().lower()


@router.post(
"/register",
response_model=UserPublic,
status_code=status.HTTP_201_CREATED,
)
def register(payload: UserRegisterRequest, db: Session = Depends(get_db)) -> User:
normalized = _normalize_email(str(payload.email))
exists = db.scalars(select(User).where(User.email == normalized)).first()
if exists:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A user with this email already exists",
)
user = User(
email=normalized,
password_hash=hash_password(payload.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED)
def register(payload: UserRegisterRequest, db: Session = Depends(get_db)):
return ctrl.register(payload, db)


@router.post("/login", response_model=TokenResponse)
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
normalized = _normalize_email(str(payload.email))
user = db.scalars(select(User).where(User.email == normalized)).first()
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
)
token = create_access_token(subject=user.id, email=user.email)
return TokenResponse(access_token=token, token_type="bearer")
def login(payload: LoginRequest, db: Session = Depends(get_db)):
return ctrl.login(payload, db)
55 changes: 55 additions & 0 deletions app/routes/orders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import uuid

from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session

from app.controllers import orders as ctrl
from app.deps import CurrentUserDep, get_db
from app.schemas.order import OrderCreate, OrderDetailOut, OrderOut, OrderUpdate

router = APIRouter()


@router.post("", response_model=OrderDetailOut, status_code=status.HTTP_201_CREATED)
def create_order(
payload: OrderCreate,
current_user: CurrentUserDep,
db: Session = Depends(get_db),
):
return ctrl.create_order(payload, current_user, db)


@router.get("", response_model=list[OrderOut])
def list_my_orders(
current_user: CurrentUserDep,
db: Session = Depends(get_db),
):
return ctrl.list_my_orders(current_user, db)


@router.get("/{order_id}", response_model=OrderDetailOut)
def get_order(
order_id: uuid.UUID,
current_user: CurrentUserDep,
db: Session = Depends(get_db),
):
return ctrl.get_order(order_id, current_user, db)


@router.patch("/{order_id}", response_model=OrderDetailOut)
def update_order(
order_id: uuid.UUID,
payload: OrderUpdate,
current_user: CurrentUserDep,
db: Session = Depends(get_db),
):
return ctrl.update_order(order_id, payload, current_user, db)


@router.post("/{order_id}/cancel", response_model=OrderDetailOut)
def cancel_order(
order_id: uuid.UUID,
current_user: CurrentUserDep,
db: Session = Depends(get_db),
):
return ctrl.cancel_order(order_id, current_user, db)
13 changes: 13 additions & 0 deletions app/routes/products.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.controllers import products as ctrl
from app.deps import get_db
from app.schemas.product import ProductOut

router = APIRouter()


@router.get("", response_model=list[ProductOut])
def list_products(db: Session = Depends(get_db)):
return ctrl.list_products(db)
Loading