Skip to content
Merged
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
2 changes: 2 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class Settings(BaseSettings):
rate_limit_user_rps: int = Field(alias='RATE_LIMIT_USER_RPS')
rate_limit_global_rps: int = Field(alias='RATE_LIMIT_GLOBAL_RPS')
rate_limit_ttl_seconds: int = Field(alias='RATE_LIMIT_TTL_SECONDS')
idempotent_key_lifetime_sec: int = Field(alias='IDEMPOTENT_KEY_LIFETIME_SEC')
reserve_timeout_minutes: int = Field(alias='RESERVE_TIMEOUT_MINUTES')
secret_key: str = Field(alias='SECRET_KEY')
debug_mode: bool = Field(default=False, alias='DEBUG_MODE')

Expand Down
34 changes: 33 additions & 1 deletion app/core/exception_handlers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
from fastapi import Request, status
from fastapi.responses import JSONResponse

from .exceptions import CredentialsError, UserAlreadyExists
from .exceptions import (
ConflictError,
CredentialsError,
InsufficientInventoryError,
NotFoundError,
UserAlreadyExists,
)

EXISTING_USER_MESSAGE = 'User already exists'
INVALID_CREDENTIALS_MESSAGE = 'Invalid credentials'
NOT_FOUND_MESSAGE = 'Resource not found'
INSUFFICIENT_INVENTORY_MESSAGE = 'Insufficient inventory'
CONFLICT_MESSAGE = 'Resource conflict'


async def user_already_exists_handler(
Expand All @@ -24,3 +33,26 @@ async def credentials_error_handler(
content={'detail': str(exc) or INVALID_CREDENTIALS_MESSAGE},
headers=getattr(exc, 'headers', None),
)


async def not_found_error_handler(request: Request, exc: NotFoundError) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'detail': str(exc) or NOT_FOUND_MESSAGE},
)


async def insufficient_inventory_error_handler(
request: Request, exc: InsufficientInventoryError
) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'detail': str(exc) or INSUFFICIENT_INVENTORY_MESSAGE},
)


async def conflict_error_handler(request: Request, exc: ConflictError) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={'detail': str(exc) or CONFLICT_MESSAGE},
)
27 changes: 24 additions & 3 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class AppError(Exception):
"""Базовый класс для всех ошибок приложения."""
"""Base class for all application errors."""

def __init__(self, message: str = '', headers: dict | None = None):
self.message = message
Expand All @@ -8,11 +8,32 @@ def __init__(self, message: str = '', headers: dict | None = None):


class UserAlreadyExists(AppError):
"""Пользователь c таким email уже существует."""
"""User with such email already exists."""


class CredentialsError(AppError):
"""Неверные учетные данные."""
"""Invalid credentials."""

def __init__(self, message: str = 'Could not validate credentials'):
super().__init__(message=message, headers={'WWW-Authenticate': 'Bearer'})


class NotFoundError(AppError):
"""Resource not found."""

def __init__(self, message: str = 'Resource not found'):
super().__init__(message=message)


class ConflictError(AppError):
"""Resource conflict."""

def __init__(self, message: str = 'Resource conflict'):
super().__init__(message=message)


class InsufficientInventoryError(AppError):
"""Insufficient inventory."""

def __init__(self, message: str = 'Insufficient inventory'):
super().__init__(message=message)
22 changes: 20 additions & 2 deletions app/core/setup.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
from fastapi import FastAPI

from .exception_handlers import credentials_error_handler, user_already_exists_handler
from .exceptions import CredentialsError, UserAlreadyExists
from .exception_handlers import (
conflict_error_handler,
credentials_error_handler,
insufficient_inventory_error_handler,
not_found_error_handler,
user_already_exists_handler,
)
from .exceptions import (
ConflictError,
CredentialsError,
InsufficientInventoryError,
NotFoundError,
UserAlreadyExists,
)


def setup_exception_handlers(app: FastAPI) -> None:
app.add_exception_handler(UserAlreadyExists, user_already_exists_handler) # type: ignore[arg-type]
app.add_exception_handler(CredentialsError, credentials_error_handler) # type: ignore[arg-type]
app.add_exception_handler(ConflictError, conflict_error_handler) # type: ignore[arg-type]
app.add_exception_handler(NotFoundError, not_found_error_handler) # type: ignore[arg-type]
app.add_exception_handler(
InsufficientInventoryError,
insufficient_inventory_error_handler, # type: ignore[arg-type]
)
8 changes: 6 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from app.core.logging import setup_logging
from app.core.lua_scripts import RATE_LIMIT_LUA_SCRIPT
from app.core.setup import setup_exception_handlers
from app.services.user.routes import router_v1
from app.services.inventory.routes import router_v1 as inventory_router_v1
from app.services.orders.routes import router_v1 as order_router_v1
from app.services.user.routes import router_v1 as user_router_v1

setup_logging()
logger = structlog.get_logger(__name__)
Expand Down Expand Up @@ -38,7 +40,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:

setup_exception_handlers(app)

app.include_router(router_v1, prefix='/api/v1', tags=['Users'])
app.include_router(user_router_v1, prefix='/api/v1', tags=['Users'])
app.include_router(order_router_v1, prefix='/api/v1', tags=['Orders'])
app.include_router(inventory_router_v1, prefix='/api/v1', tags=['Inventory'])


@app.get('/health')
Expand Down
4 changes: 2 additions & 2 deletions app/services/inventory/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ class Product(Base):
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(String(), nullable=False)
description: Mapped[str | None] = mapped_column(Text(), nullable=True)
price: Mapped[Decimal | None] = mapped_column(
price: Mapped[Decimal] = mapped_column(
Numeric(DECIMAL_PRECISION, DECIMAL_SCALE),
nullable=True,
nullable=False,
default=Decimal('0.10'),
)
qty_available: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
Expand Down
35 changes: 35 additions & 0 deletions app/services/inventory/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, Header, Request
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_session
from app.services.inventory.models import Reservation
from app.services.inventory.rate_limit import check_rate_limit
from app.services.inventory.schemas import ReservationCreate, ReservationResponse
from app.services.inventory.service import reserve_items
from app.services.user.models import User
from app.shared.decorators import idempotent
from app.shared.deps import get_current_user

router_v1 = APIRouter(prefix='/inventory', tags=['Inventory'])


@router_v1.post('/reserve', response_model=ReservationResponse)
@idempotent()
async def reservation_data(
request: Request,
reservation_data: ReservationCreate,
x_idempotency_key: str = Header(...),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> Reservation:
await check_rate_limit(
rate_limit_script=request.app.state.rate_limit_script,
user_id=str(current_user.id),
item_id=str(reservation_data.product_id),
)
return await reserve_items(
session=session,
user_id=current_user.id,
idempotency_key=x_idempotency_key,
reservation_data=reservation_data,
)
19 changes: 19 additions & 0 deletions app/services/inventory/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from datetime import datetime
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field


class ReservationCreate(BaseModel):
product_id: UUID
quantity: int = Field(gt=0, description='Quantity must be greater than 0')


class ReservationResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
product_id: UUID
user_id: UUID
quantity: int
status: str
expires_at: datetime
49 changes: 49 additions & 0 deletions app/services/inventory/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import datetime
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import settings
from app.core.exceptions import ConflictError, InsufficientInventoryError, NotFoundError
from app.services.inventory.models import Product, Reservation
from app.services.inventory.schemas import ReservationCreate


async def reserve_items(
session: AsyncSession,
user_id: UUID,
idempotency_key: str,
reservation_data: ReservationCreate,
) -> Reservation:
result = await session.execute(
select(Product)
.with_for_update()
.where(Product.id == reservation_data.product_id)
)
product = result.scalar_one_or_none()
if not product:
raise NotFoundError
if product.qty_available < reservation_data.quantity:
raise InsufficientInventoryError
product.qty_available -= reservation_data.quantity
expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
minutes=settings.reserve_timeout_minutes
)
new_reservation = Reservation(
qty_reserved=reservation_data.quantity,
user_id=user_id,
product_id=reservation_data.product_id,
status='pending',
idempotency_key=idempotency_key,
expires_at=expires_at,
)
session.add(new_reservation)
try:
await session.commit()
await session.refresh(new_reservation)
return new_reservation
except IntegrityError:
await session.rollback()
raise ConflictError
1 change: 1 addition & 0 deletions app/services/orders/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class OrderStatus(StrEnum):
SHIPPED = 'shipped'
CANCELLED = 'cancelled'
FAILED = 'failed'
COMPLETED = 'completed'


class Order(Base):
Expand Down
66 changes: 66 additions & 0 deletions app/services/orders/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from uuid import UUID

from fastapi import APIRouter, Depends, Header, Request
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_session
from app.services.orders.models import Order
from app.services.orders.schemas import OrderCreate, OrderResponse
from app.services.orders.service import (
cancel_order,
confirm_order_payment,
create_order_from_reservation,
)
from app.services.user.models import User
from app.shared.decorators import idempotent
from app.shared.deps import get_current_user

router_v1 = APIRouter(prefix='/orders', tags=['Orders'])


@router_v1.post('/', response_model=OrderResponse)
@idempotent()
async def create_order_endpoint(
request: Request,
order_data: OrderCreate,
x_idempotency_key: str = Header(...),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> Order:
return await create_order_from_reservation(
session=session,
user_id=current_user.id,
order_data=order_data,
)


@router_v1.post('/{order_id}/pay', response_model=OrderResponse)
@idempotent()
async def confirm_order_payment_endpoint(
request: Request,
order_id: UUID,
x_idempotency_key: str = Header(...),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> Order:
return await confirm_order_payment(
session=session,
order_id=order_id,
user_id=current_user.id,
)


@router_v1.post('/{order_id}/cancel', response_model=OrderResponse)
@idempotent()
async def cancel_order_endpoint(
request: Request,
order_id: UUID,
x_idempotency_key: str = Header(...),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> Order:
return await cancel_order(
session=session,
order_id=order_id,
user_id=current_user.id,
)
30 changes: 30 additions & 0 deletions app/services/orders/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from datetime import datetime
from decimal import Decimal
from uuid import UUID

from pydantic import BaseModel, ConfigDict


class OrderCreate(BaseModel):
reservation_id: UUID
shipping_address: str | None = None


class OrderItemResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
product_id: UUID
product_name: str
quantity: int
price: Decimal


class OrderResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
user_id: UUID
status: str
total_amount: Decimal
shipping_address: str | None = None
created_at: datetime
items: list[OrderItemResponse]
Loading