Skip to content
Draft
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
12 changes: 8 additions & 4 deletions backend/app/api/routes/v1/auth.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, status
from fastapi.security import OAuth2PasswordRequestForm

from app.config import settings
from app.database import DbSession
from app.schemas.auth import TokenResponse
from app.schemas.model_crud.user_management import DeveloperRead, DeveloperUpdate, PasswordChange
from app.services import DeveloperDep, developer_service, refresh_token_service
from app.utils.exceptions import ApiError
from app.utils.security import create_access_token, verify_password

router = APIRouter()
Expand All @@ -28,16 +29,18 @@ def login(
sort_by=None,
)
if not developers:
raise HTTPException(
raise ApiError(
status_code=status.HTTP_401_UNAUTHORIZED,
code="INVALID_CREDENTIALS",
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)

developer = developers[0]
if not verify_password(form_data.password, developer.hashed_password):
raise HTTPException(
raise ApiError(
status_code=status.HTTP_401_UNAUTHORIZED,
code="INVALID_CREDENTIALS",
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
Expand Down Expand Up @@ -68,8 +71,9 @@ def change_password(
"""Change password for the current authenticated developer."""
# Verify the current password
if not verify_password(payload.current_password, developer.hashed_password):
raise HTTPException(
raise ApiError(
status_code=status.HTTP_400_BAD_REQUEST,
code="INCORRECT_CURRENT_PASSWORD",
detail="Incorrect current password",
)

Expand Down
15 changes: 11 additions & 4 deletions backend/app/api/routes/v1/events.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Query, status

from app.database import DbSession
from app.schemas.model_crud.activities import EventRecordQueryParams
Expand All @@ -14,6 +14,7 @@
from app.services import ApiKeyDep
from app.services.event_record_service import event_record_service
from app.utils.dates import DateTimeQueryParam, parse_query_datetime
from app.utils.exceptions import ApiError

router = APIRouter()

Expand Down Expand Up @@ -89,7 +90,7 @@ def delete_workout(
) -> None:
"""Delete a workout session."""
if not event_record_service.delete_event_record(db, user_id, workout_id, "workout"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout not found")
raise ApiError(status_code=status.HTTP_404_NOT_FOUND, code="WORKOUT_NOT_FOUND", detail="Workout not found")


@router.delete("/users/{user_id}/events/sleep/{sleep_id}", status_code=status.HTTP_204_NO_CONTENT)
Expand All @@ -101,7 +102,9 @@ def delete_sleep_session(
) -> None:
"""Delete a sleep session."""
if not event_record_service.delete_event_record(db, user_id, sleep_id, "sleep"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sleep session not found")
raise ApiError(
status_code=status.HTTP_404_NOT_FOUND, code="SLEEP_SESSION_NOT_FOUND", detail="Sleep session not found"
)


@router.delete("/users/{user_id}/events/menstrual-cycles/{cycle_id}", status_code=status.HTTP_204_NO_CONTENT)
Expand All @@ -113,4 +116,8 @@ def delete_menstrual_cycle(
) -> None:
"""Delete a menstrual cycle record."""
if not event_record_service.delete_event_record(db, user_id, cycle_id, "menstrual_cycle"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Menstrual cycle record not found")
raise ApiError(
status_code=status.HTTP_404_NOT_FOUND,
code="MENSTRUAL_CYCLE_NOT_FOUND",
detail="Menstrual cycle record not found",
)
7 changes: 4 additions & 3 deletions backend/app/api/routes/v1/import_xml.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json
from json import JSONDecodeError

from fastapi import APIRouter, HTTPException, Request, UploadFile, status
from fastapi import APIRouter, Request, UploadFile, status
from pydantic import ValidationError

from app.integrations.celery.tasks.process_xml_upload_task import process_xml_upload
Expand All @@ -14,6 +14,7 @@
from app.services import ApiKeyDep
from app.services.apple.apple_xml.presigned_url_service import presigned_url_service
from app.services.apple.apple_xml.sns_service import sns_service
from app.utils.exceptions import ApiError

router = APIRouter()

Expand Down Expand Up @@ -56,10 +57,10 @@ async def receive_sns_notification(
try:
notification = SNSNotification.model_validate(json.loads(body))
except (ValidationError, JSONDecodeError) as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise ApiError(status_code=status.HTTP_400_BAD_REQUEST, code="INVALID_SNS_NOTIFICATION", detail=str(e))

result = await sns_service.handle_sns_notification(notification)

if result.status_code not in (status.HTTP_200_OK, status.HTTP_202_ACCEPTED):
raise HTTPException(status_code=result.status_code, detail=result.response)
raise ApiError(status_code=result.status_code, code="SNS_NOTIFICATION_FAILED", detail=result.response)
return result
10 changes: 7 additions & 3 deletions backend/app/api/routes/v1/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, Query, status
from fastapi.responses import RedirectResponse

from app.config import settings
Expand All @@ -18,6 +18,7 @@
from app.services.provider_settings_service import ProviderSettingsService
from app.services.providers.base_strategy import BaseProviderStrategy
from app.services.providers.factory import ProviderFactory
from app.utils.exceptions import ApiError, UnsupportedProviderError

router = APIRouter()
factory = ProviderFactory()
Expand All @@ -29,8 +30,9 @@ def get_oauth_strategy(provider: ProviderName) -> BaseProviderStrategy:
strategy = factory.get_provider(provider.value)

if not strategy.oauth:
raise HTTPException(
raise ApiError(
status_code=status.HTTP_400_BAD_REQUEST,
code="UNSUPPORTED_PROVIDER_OPERATION",
detail=f"Provider '{provider.value}' does not support OAuth",
)
return strategy
Expand Down Expand Up @@ -184,8 +186,10 @@ def update_provider_setting(
"""Update is_enabled and/or live_sync_mode for a single provider."""
try:
return settings_service.update_provider_setting(db, provider, update)
except UnsupportedProviderError as e:
raise ApiError(status_code=status.HTTP_400_BAD_REQUEST, code=e.code, detail=e.detail)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise ApiError(status_code=status.HTTP_400_BAD_REQUEST, code="INVALID_PROVIDER", detail=str(e))


@router.put("/providers", response_model=list[ProviderSettingRead], tags=["Internal: Providers"])
Expand Down
12 changes: 9 additions & 3 deletions backend/app/api/routes/v1/outgoing_webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from datetime import datetime
from typing import Annotated, Any

from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, Query, status
from svix.api import EndpointOut, MessageAttemptListByEndpointOptions, MessageListOptions, MessageStatus

from app.schemas.webhooks.endpoints import (
Expand All @@ -30,6 +30,7 @@
from app.schemas.webhooks.event_types import EVENT_TYPE_DESCRIPTIONS, EVENT_TYPE_GROUPS, WebhookEventType
from app.services import DeveloperDep
from app.services.outgoing_webhooks import svix as svix_service
from app.utils.exceptions import ApiError

router = APIRouter()

Expand All @@ -47,8 +48,9 @@ def _ep_to_response(ep: EndpointOut) -> EndpointResponse:
def _svix_app_id(developer: DeveloperDep) -> str:
"""Authenticate the developer, assert Svix is configured, and return the app UID."""
if not svix_service.is_enabled():
raise HTTPException(
raise ApiError(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
code="WEBHOOKS_NOT_CONFIGURED",
detail="Outgoing webhooks are not configured (set SVIX_JWT_SECRET or SVIX_AUTH_TOKEN).",
)
return svix_service.ensure_application(str(developer.id), developer.email)
Expand Down Expand Up @@ -226,5 +228,9 @@ def send_test_event(endpoint_id: str, app_id: SvixAppId, body: TestEventRequest
event_type = body.event_type if body else WebhookEventType.WORKOUT_CREATED
result = svix_service.send_test_message(app_id, endpoint_id, event_type)
if result is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to send test event.")
raise ApiError(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="WEBHOOK_TEST_FAILED",
detail="Failed to send test event.",
)
return {"message": "Test event sent successfully.", "message_id": result.id}
6 changes: 4 additions & 2 deletions backend/app/api/routes/v1/sdk_logs.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import uuid
from logging import getLogger

from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, status

from app.schemas.providers.mobile_sdk import SDKLogRequest
from app.schemas.responses.upload import UploadDataResponse
from app.services.raw_payload_storage import store_raw_payload
from app.utils.auth import SDKAuthDep
from app.utils.exceptions import ApiError
from app.utils.structured_logging import log_structured

router = APIRouter()
Expand All @@ -25,8 +26,9 @@ def submit_sdk_logs(
lifecycle, device state, sync success/failure).
"""
if auth.auth_type == "sdk_token" and (not auth.user_id or str(auth.user_id) != user_id):
raise HTTPException(
raise ApiError(
status_code=status.HTTP_403_FORBIDDEN,
code="PERMISSION_DENIED",
detail="Token does not match user_id",
)

Expand Down
9 changes: 6 additions & 3 deletions backend/app/api/routes/v1/sdk_sync.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import uuid
from logging import getLogger

from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, status

from app.integrations.celery.tasks.process_sdk_upload_task import process_sdk_upload
from app.schemas.providers.mobile_sdk import SyncRequest
from app.schemas.responses.upload import UploadDataResponse
from app.services.raw_payload_storage import store_raw_payload
from app.utils.auth import SDKAuthDep
from app.utils.exceptions import ApiError
from app.utils.structured_logging import log_structured

router = APIRouter()
Expand Down Expand Up @@ -48,8 +49,9 @@ def sync_sdk_data(
HTTPException: 403 if token doesn't match user_id, 400 if provider unsupported
"""
if auth.auth_type == "sdk_token" and (not auth.user_id or str(auth.user_id) != user_id):
raise HTTPException(
raise ApiError(
status_code=status.HTTP_403_FORBIDDEN,
code="PERMISSION_DENIED",
detail="Token does not match user_id",
)

Expand All @@ -58,8 +60,9 @@ def sync_sdk_data(

# Validate provider
if provider not in ("apple", "samsung", "google"):
raise HTTPException(
raise ApiError(
status_code=status.HTTP_400_BAD_REQUEST,
code="INVALID_PROVIDER",
detail=f"Unsupported provider: {provider}. Supported: apple, samsung, google",
)

Expand Down
6 changes: 4 additions & 2 deletions backend/app/api/routes/v1/sdk_token.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Body, HTTPException, status
from fastapi import APIRouter, Body, status

from app.config import settings
from app.database import DbSession
from app.schemas.auth import SDKTokenRequest, TokenResponse
from app.services import application_service, create_sdk_user_token, refresh_token_service
from app.utils.auth import DeveloperOptionalDep
from app.utils.exceptions import ApiError

router = APIRouter()

Expand Down Expand Up @@ -57,8 +58,9 @@ def create_user_token(
app_id = f"admin:{developer.id}"
else:
# Neither method provided
raise HTTPException(
raise ApiError(
status_code=status.HTTP_400_BAD_REQUEST,
code="MISSING_APP_CREDENTIALS",
detail="Either app credentials (app_id, app_secret) or admin authentication (Bearer token) is required",
)

Expand Down
27 changes: 17 additions & 10 deletions backend/app/api/routes/v1/sync_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Annotated, Any
from uuid import UUID

from fastapi import APIRouter, HTTPException, Path, Query, status
from fastapi import APIRouter, Path, Query, status

from app.database import DbSession
from app.integrations.celery.tasks import (
Expand All @@ -18,7 +18,7 @@
from app.schemas.enums import ProviderName
from app.services import ApiKeyDep
from app.services.providers.factory import ProviderFactory
from app.utils.exceptions import UnsupportedProviderError
from app.utils.exceptions import ApiError, UnsupportedProviderError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -136,8 +136,9 @@ def sync_user_data(
}
unsupported = [k for k, v in non_default_params.items() if v]
if unsupported:
raise HTTPException(
raise ApiError(
status_code=status.HTTP_400_BAD_REQUEST,
code="UNSUPPORTED_SYNC_PARAMETERS",
detail=(
f"Parameters {unsupported} are not supported in async mode. "
"Use async=false or omit provider-specific parameters."
Expand Down Expand Up @@ -181,8 +182,9 @@ def sync_user_data(
if strategy.workouts:
results["workouts"] = strategy.workouts.load_data(db, user_id, **params)
elif data_type == SyncDataType.WORKOUTS:
raise HTTPException(
raise ApiError(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
code="UNSUPPORTED_PROVIDER_OPERATION",
detail=f"Provider '{provider.value}' does not support workouts",
)

Expand All @@ -198,14 +200,16 @@ def sync_user_data(
end_dt = datetime.now()
results["data_247"] = load_fn(db, user_id, start_time=start_dt, end_time=end_dt)
elif data_type == SyncDataType.DATA_247:
raise HTTPException(
raise ApiError(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
code="UNSUPPORTED_PROVIDER_OPERATION",
detail=f"Provider '{provider.value}' does not support 247 data (sleep/recovery/activity)",
)

if not results:
raise HTTPException(
raise ApiError(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
code="UNSUPPORTED_PROVIDER_OPERATION",
detail=f"Provider '{provider.value}' does not support any requested data types",
)

Expand Down Expand Up @@ -271,8 +275,9 @@ def cancel_garmin_backfill(
"""
backfill_status = get_garmin_backfill_status(str(user_id))
if backfill_status["overall_status"] not in ("in_progress", "retry_in_progress"):
raise HTTPException(
raise ApiError(
status_code=status.HTTP_409_CONFLICT,
code="BACKFILL_NOT_IN_PROGRESS",
detail="No backfill in progress for this user",
)

Expand Down Expand Up @@ -305,8 +310,9 @@ def retry_garmin_backfill_type(
Dict with retry status
"""
if type_name not in GARMIN_BACKFILL_DATA_TYPES:
raise HTTPException(
raise ApiError(
status_code=status.HTTP_400_BAD_REQUEST,
code="INVALID_BACKFILL_TYPE",
detail=f"Invalid type: {type_name}. Valid types: {', '.join(GARMIN_BACKFILL_DATA_TYPES)}",
)

Expand Down Expand Up @@ -371,10 +377,11 @@ def sync_historical_data(
try:
result = strategy.start_historical_sync(user_id, days)
except UnsupportedProviderError as exc:
raise HTTPException(
raise ApiError(
status_code=status.HTTP_400_BAD_REQUEST,
code=exc.code,
detail=exc.detail,
)
) from exc

return {
"success": True,
Expand Down
Loading
Loading