feat: merge cartsnitch/api into api/ subdirectory

Consolidate API gateway service into monorepo.
Squashed from https://github.com/cartsnitch/api main (89bacb1).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Coupon Carl
2026-03-28 02:24:02 +00:00
commit b7e6f637a7
91 changed files with 6296 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
"""Alert routes: list alerts, manage settings."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import AlertResponse, AlertSettingsRequest, AlertSettingsResponse
from cartsnitch_api.services.alerts import AlertService
router = APIRouter(prefix="/alerts", tags=["alerts"])
@router.get("", response_model=list[AlertResponse])
async def list_alerts(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AlertService(db)
return await svc.list_alerts(user_id)
@router.get("/settings", response_model=AlertSettingsResponse)
async def get_alert_settings(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AlertService(db)
return await svc.get_settings(user_id)
@router.put("/settings")
async def update_alert_settings(
body: AlertSettingsRequest,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Alert settings persistence not yet implemented. "
"Use GET /alerts/settings for current defaults.",
)
+32
View File
@@ -0,0 +1,32 @@
"""Coupon routes: browse, relevant matches."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import CouponResponse
from cartsnitch_api.services.coupons import CouponService
router = APIRouter(prefix="/coupons", tags=["coupons"])
@router.get("", response_model=list[CouponResponse])
async def list_coupons(
store_id: UUID | None = Query(None),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = CouponService(db)
return await svc.list_coupons(store_id)
@router.get("/relevant", response_model=list[CouponResponse])
async def relevant_coupons(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = CouponService(db)
return await svc.relevant_coupons(user_id)
+20
View File
@@ -0,0 +1,20 @@
"""Health check and error metrics endpoints."""
from fastapi import APIRouter, Depends
from cartsnitch_api.auth.dependencies import verify_service_key
from cartsnitch_api.middleware.error_handler import get_error_monitor
router = APIRouter(tags=["health"])
@router.get("/health")
async def health():
return {"status": "ok"}
@router.get("/internal/error-stats", dependencies=[Depends(verify_service_key)])
async def error_stats():
"""Error monitoring stats — internal only (requires X-Service-Key)."""
monitor = get_error_monitor()
return monitor.get_stats()
+47
View File
@@ -0,0 +1,47 @@
"""Price routes: trends, increases, comparison."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import (
PriceComparisonResponse,
PriceIncreaseResponse,
PriceTrendResponse,
)
from cartsnitch_api.services.prices import PriceService
router = APIRouter(prefix="/prices", tags=["prices"])
@router.get("/trends", response_model=list[PriceTrendResponse])
async def price_trends(
user_id: UUID = Depends(get_current_user),
category: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
return await svc.get_trends(category)
@router.get("/increases", response_model=list[PriceIncreaseResponse])
async def price_increases(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
return await svc.get_increases()
@router.get("/comparison", response_model=list[PriceComparisonResponse])
async def price_comparison(
product_ids: Annotated[list[UUID], Query()],
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
return await svc.get_comparison(product_ids)
+56
View File
@@ -0,0 +1,56 @@
"""Product routes: search/list, detail, price history."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import PriceTrendResponse, ProductDetailResponse, ProductResponse
from cartsnitch_api.services.products import ProductService
router = APIRouter(prefix="/products", tags=["products"])
@router.get("", response_model=list[ProductResponse])
async def list_products(
user_id: UUID = Depends(get_current_user),
q: str | None = Query(None),
category: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
return await svc.list_products(q, category, page, page_size)
@router.get("/{product_id}", response_model=ProductDetailResponse)
async def get_product(
product_id: UUID,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
try:
return await svc.get_product(product_id)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
) from None
@router.get("/{product_id}/prices", response_model=PriceTrendResponse)
async def get_product_prices(
product_id: UUID,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
try:
return await svc.get_price_history(product_id)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
) from None
+48
View File
@@ -0,0 +1,48 @@
"""Public endpoints: price transparency data (no auth required)."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import (
PublicInflationResponse,
PublicStoreComparisonResponse,
PublicTrendResponse,
)
from cartsnitch_api.services.public import PublicService
router = APIRouter(prefix="/public", tags=["public"])
@router.get("/trends/{product_id}", response_model=PublicTrendResponse)
async def public_price_trend(product_id: UUID, db: AsyncSession = Depends(get_db)):
svc = PublicService(db)
try:
return await svc.get_trend(product_id)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
) from None
@router.get("/store-comparison", response_model=PublicStoreComparisonResponse)
async def public_store_comparison(
product_ids: Annotated[list[UUID], Query(max_length=20)],
db: AsyncSession = Depends(get_db),
):
if not product_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one product_id is required",
)
svc = PublicService(db)
return await svc.get_store_comparison(product_ids)
@router.get("/inflation", response_model=PublicInflationResponse)
async def public_inflation(db: AsyncSession = Depends(get_db)):
svc = PublicService(db)
return await svc.get_inflation()
+49
View File
@@ -0,0 +1,49 @@
"""Purchase routes: list, detail, stats."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import PurchaseDetailResponse, PurchaseResponse, PurchaseStatsResponse
from cartsnitch_api.services.purchases import PurchaseService
router = APIRouter(prefix="/purchases", tags=["purchases"])
@router.get("", response_model=list[PurchaseResponse])
async def list_purchases(
user_id: UUID = Depends(get_current_user),
store_id: UUID | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
return await svc.list_purchases(user_id, store_id, page, page_size)
@router.get("/stats", response_model=PurchaseStatsResponse)
async def purchase_stats(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
return await svc.get_stats(user_id)
@router.get("/{purchase_id}", response_model=PurchaseDetailResponse)
async def get_purchase(
purchase_id: UUID,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
try:
return await svc.get_purchase(purchase_id, user_id)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Purchase not found"
) from None
+42
View File
@@ -0,0 +1,42 @@
"""Scraping routes: trigger sync, check status (proxy to ReceiptWitness)."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.schemas import SyncStatusResponse, SyncTriggerResponse
from cartsnitch_api.services.receiptwitness import ReceiptWitnessClient
router = APIRouter(prefix="/scraping", tags=["scraping"])
@router.post("/{store_slug}/sync", response_model=SyncTriggerResponse)
async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user)):
client = ReceiptWitnessClient()
try:
result = await client.trigger_sync(str(user_id), store_slug)
return result
except HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail="Sync service error",
) from e
except RequestError:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Unable to reach sync service",
) from None
@router.get("/status", response_model=list[SyncStatusResponse])
async def sync_status(user_id: UUID = Depends(get_current_user)):
client = ReceiptWitnessClient()
try:
return await client.get_sync_status(str(user_id))
except (HTTPStatusError, RequestError):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Unable to reach sync service",
) from None
+48
View File
@@ -0,0 +1,48 @@
"""Shopping routes: optimize list, saved lists."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.schemas import OptimizeRequest, OptimizeResponse, ShoppingListResponse
from cartsnitch_api.services.clipartist import ClipArtistClient
router = APIRouter(prefix="/shopping", tags=["shopping"])
@router.post("/optimize", response_model=OptimizeResponse)
async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_current_user)):
client = ClipArtistClient()
try:
result = await client.optimize(
user_id=str(user_id),
items=[item.model_dump() for item in body.items],
preferred_stores=(
[str(s) for s in body.preferred_stores] if body.preferred_stores else None
),
)
return result
except HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail="Shopping optimization service error",
) from e
except RequestError:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Unable to reach shopping optimization service",
) from None
@router.get("/lists", response_model=list[ShoppingListResponse])
async def list_shopping_lists(user_id: UUID = Depends(get_current_user)):
client = ClipArtistClient()
try:
return await client.get_shopping_lists(str(user_id))
except (HTTPStatusError, RequestError):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Unable to reach shopping service",
) from None
+61
View File
@@ -0,0 +1,61 @@
"""Store routes: list stores, manage user store connections."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import ConnectStoreRequest, StoreAccountResponse, StoreResponse
from cartsnitch_api.services.stores import StoreService
router = APIRouter(tags=["stores"])
@router.get("/stores", response_model=list[StoreResponse])
async def list_stores(db: AsyncSession = Depends(get_db)):
svc = StoreService(db)
return await svc.list_stores()
@router.get("/me/stores", response_model=list[StoreAccountResponse])
async def list_user_stores(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
return await svc.list_user_stores(user_id)
@router.post(
"/me/stores/{store_slug}/connect",
response_model=StoreAccountResponse,
status_code=status.HTTP_201_CREATED,
)
async def connect_store(
store_slug: str,
body: ConnectStoreRequest,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
try:
return await svc.connect_store(user_id, store_slug, body.credentials)
except LookupError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
@router.delete("/me/stores/{store_slug}", status_code=status.HTTP_204_NO_CONTENT)
async def disconnect_store(
store_slug: str,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
try:
await svc.disconnect_store(user_id, store_slug)
except LookupError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e