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:
@@ -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.",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user