feat: T03 backend core – FastAPI + DB models + Equipment API + Redis cache + health + tests
- FastAPI app with CORS, lifespan handlers - Pydantic Settings config (DB, Redis, CORS, SMTP, JWT, Rentman) - SQLAlchemy async engine + session (DeclarativeBase) - 6 DB models: EquipmentCache, RentalRequest, RentalRequestItem, Contact, AdminUser, SyncLog - Pydantic schemas: EquipmentItem, EquipmentDetail, PaginatedEquipment, ContactCreate, ContactResponse - Redis cache helper: set/get/delete_pattern, rate limiting, equipment key builders - Equipment router: list (search/category/sort/pagination), detail, categories – all cached - Contact router: POST with Pydantic validation + rate limiting (5/min) - Health router: GET /api/health with DB + Redis status - 28 pytest tests (all pass, 90% coverage) - Dockerfile, requirements.txt, pytest.ini, test_report.md
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""Redis cache helper for equipment data and rate limiting."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_cache_client: redis.Redis | None = None
|
||||
|
||||
|
||||
def _build_key(*parts: str) -> str:
|
||||
"""Build a colon-separated Redis key."""
|
||||
return ":".join(part.replace(":", "_") for part in parts)
|
||||
|
||||
|
||||
async def get_cache() -> redis.Redis:
|
||||
"""Return the Redis client singleton, creating it on first use."""
|
||||
global _cache_client
|
||||
if _cache_client is None:
|
||||
_cache_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
return _cache_client
|
||||
|
||||
|
||||
async def set_cache(key: str, value: Any, ttl: int = 3600) -> None:
|
||||
"""Store a JSON-serialisable value in Redis with a TTL."""
|
||||
try:
|
||||
client = await get_cache()
|
||||
await client.setex(key, ttl, json.dumps(value))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Cache set failed for key=%s: %s", key, exc)
|
||||
|
||||
|
||||
async def get_cached(key: str) -> Any | None:
|
||||
"""Retrieve and JSON-deserialise a cached value."""
|
||||
try:
|
||||
client = await get_cache()
|
||||
raw = await client.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Cache get failed for key=%s: %s", key, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def delete_pattern(pattern: str) -> None:
|
||||
"""Delete all keys matching a glob pattern."""
|
||||
try:
|
||||
client = await get_cache()
|
||||
async for key in client.scan_iter(match=pattern, count=100):
|
||||
await client.delete(key)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Cache delete_pattern failed for pattern=%s: %s", pattern, exc)
|
||||
|
||||
|
||||
async def check_rate_limit(identifier: str, max_requests: int = 5, window: int = 60) -> bool:
|
||||
"""Return True if the identifier is within the rate limit."""
|
||||
try:
|
||||
client = await get_cache()
|
||||
key = _build_key("rate", identifier)
|
||||
count = await client.incr(key)
|
||||
if count == 1:
|
||||
await client.expire(key, window)
|
||||
return count <= max_requests
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Rate limit check failed: %s", exc)
|
||||
return True
|
||||
|
||||
|
||||
async def ping_cache() -> bool:
|
||||
"""Check if Redis is reachable."""
|
||||
try:
|
||||
client = await get_cache()
|
||||
return bool(await client.ping())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def equipment_list_key(page: int, category: str | None, sort: str | None, search: str | None) -> str:
|
||||
"""Build the Redis cache key for an equipment list query."""
|
||||
cat = category or "all"
|
||||
srt = sort or "default"
|
||||
srch = search or "all"
|
||||
return _build_key("equipment", "list", str(page), cat, srt, srch)
|
||||
|
||||
|
||||
def equipment_detail_key(item_id: int) -> str:
|
||||
"""Build the Redis cache key for an equipment detail."""
|
||||
return _build_key("equipment", "detail", str(item_id))
|
||||
|
||||
|
||||
def equipment_categories_key() -> str:
|
||||
"""Build the Redis cache key for the equipment categories list."""
|
||||
return _build_key("equipment", "categories")
|
||||
Reference in New Issue
Block a user