| """ |
| interface/api/routes/ppg_routes.py |
| ββββββββββββββββββββββββββββββββββββ |
| REST endpoints for PPG signal ingestion (ETL #1). |
| |
| Endpoints: |
| POST /api/v1/ppg/ingest β Receive PPG from mobile app, store + queue |
| GET /api/v1/ppg/{user_id} β Query stored PPG signals for a user |
| |
| Error handling: |
| All domain exceptions (InvalidSignalError, ConflictError, DatabaseError, |
| BrokerError, etc.) bubble up to the global handlers registered in |
| error_handlers.py. Route handlers are intentionally kept exception-free |
| for readability. |
| """ |
| from __future__ import annotations |
|
|
| from typing import Annotated |
|
|
| from fastapi import APIRouter, Depends, status |
|
|
| from src.application.dto.ppg_dto import PPGIngestRequest, PPGIngestResponse |
| from src.application.use_cases.ingest_ppg import IngestPPGUseCase |
| from src.interface.api.dependencies import get_ingest_ppg_use_case |
| from src.shared.logger import get_logger |
|
|
| logger = get_logger(__name__) |
|
|
| router = APIRouter( |
| prefix="/ppg", |
| tags=["PPG Ingestion"], |
| ) |
|
|
|
|
| @router.post( |
| "/ingest", |
| response_model=PPGIngestResponse, |
| status_code=status.HTTP_201_CREATED, |
| summary="Ingest a PPG Signal", |
| description=( |
| "Receive a raw PPG signal from the mobile app (IoT sensor), validate it, " |
| "persist it to the database, and publish it to the message queue for " |
| "downstream AI processing (ETL #1)." |
| ), |
| responses={ |
| 201: {"description": "Signal ingested and queued successfully."}, |
| 400: { |
| "description": "Invalid signal β domain validation failed.", |
| "content": { |
| "application/json": { |
| "example": { |
| "error": "invalid_signal", |
| "message": "Invalid PPG signal β field 'sampling_rate': ...", |
| "context": {"field": "sampling_rate", "value": -1, "reason": "..."}, |
| "timestamp": "2026-05-31T09:00:00+00:00", |
| } |
| } |
| }, |
| }, |
| 409: { |
| "description": "Conflict β duplicate signal record.", |
| "content": { |
| "application/json": { |
| "example": { |
| "error": "conflict", |
| "message": "Conflict on PPGModel: A record with the same unique key already exists.", |
| "context": {}, |
| "timestamp": "2026-05-31T09:00:00+00:00", |
| } |
| } |
| }, |
| }, |
| 422: {"description": "Unprocessable β Pydantic request validation failed."}, |
| 503: { |
| "description": "Service unavailable β database or broker is down.", |
| "content": { |
| "application/json": { |
| "example": { |
| "error": "database_unavailable", |
| "message": "The database is temporarily unavailable.", |
| "context": {}, |
| "timestamp": "2026-05-31T09:00:00+00:00", |
| } |
| } |
| }, |
| }, |
| }, |
| ) |
| async def ingest_ppg( |
| request: PPGIngestRequest, |
| use_case: Annotated[IngestPPGUseCase, Depends(get_ingest_ppg_use_case)], |
| ) -> PPGIngestResponse: |
| """ |
| **ETL #1 β Entry Point** |
| |
| Accepts a JSON payload from the smartphone, validates it against domain |
| rules, stores the raw PPG in PostgreSQL (Supabase), and publishes an event |
| to RabbitMQ for the AI processing consumer (ETL #2). |
| |
| Returns the stored signal ID and queue confirmation. |
| |
| **Possible errors:** |
| - `400 invalid_signal` β sampling rate out of range, signal too short, etc. |
| - `409 conflict` β duplicate signal ID submitted. |
| - `503 database_unavailable` β Supabase is temporarily unreachable. |
| - `503 broker_unavailable` β RabbitMQ/CloudAMQP is temporarily unreachable. |
| """ |
| logger.info( |
| "POST /ppg/ingest β device=%s user=%s", |
| request.device_id, |
| request.user_id, |
| ) |
| return await use_case.execute(request) |
|
|