"""FastAPI façade exposing the voip.ms SMS/MMS operations over HTTP. Run: export VOIPMS_API_USERNAME=... VOIPMS_API_PASSWORD=... uvicorn sms.app:app --reload Then open http://127.0.0.1:8000/docs """ from __future__ import annotations import logging from contextlib import asynccontextmanager from typing import Annotated from fastapi import FastAPI, Query, Request, status from fastapi.responses import JSONResponse, PlainTextResponse from .client import VoipMsSMSClient from .config import Settings from .exceptions import VoipMsApiError, VoipMsAuthError, VoipMsError, VoipMsRateLimitError from .guard import DailySendCounter from .models import ( DeleteResult, MediaResult, MmsRecord, SendMmsRequest, SendResult, SendSmsRequest, SmsRecord, ) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") @asynccontextmanager async def lifespan(app: FastAPI): settings = Settings() # validates env creds; raises clearly if missing app.state.settings = settings app.state.client = VoipMsSMSClient(settings) app.state.guard = DailySendCounter(limit=settings.daily_limit) try: yield finally: await app.state.client.aclose() app = FastAPI( title="voip.ms SMS/MMS API façade", description="Thin typed proxy over the voip.ms SMS/MMS REST API.", version="0.1.0", lifespan=lifespan, ) def _client(request: Request) -> VoipMsSMSClient: return request.app.state.client def _guard(request: Request) -> DailySendCounter: return request.app.state.guard def _map_error(exc: VoipMsError) -> JSONResponse: if isinstance(exc, VoipMsRateLimitError): return JSONResponse( status_code=status.HTTP_429_TOO_MANY_REQUESTS, content={"detail": str(exc), "limit": exc.limit, "used_today": exc.used_today}, ) if isinstance(exc, VoipMsAuthError): # 500 — don't leak auth context to callers; log it server-side. logging.getLogger("voipms.sms.app").error("auth error: %s", exc) return JSONResponse(status_code=500, content={"detail": "upstream authentication failure"}) return JSONResponse( status_code=status.HTTP_502_BAD_GATEWAY, content={"detail": str(exc)}, ) # --- outbound (guarded) ---------------------------------------------------- @app.post("/sms/send", response_model=SendResult) async def send_sms(body: SendSmsRequest, request: Request) -> SendResult: guard = _guard(request) client = _client(request) try: guard.check_and_increment() result = await client.send_sms(did=body.did, dst=body.dst, message=body.message) except VoipMsError as exc: return _map_error(exc) guard.log_send(kind="sms", did=body.did, dst=body.dst, chars=len(body.message)) return result @app.post("/mms/send", response_model=SendResult) async def send_mms(body: SendMmsRequest, request: Request) -> SendResult: guard = _guard(request) client = _client(request) try: guard.check_and_increment() result = await client.send_mms( did=body.did, dst=body.dst, message=body.message, media1=body.media1, media2=body.media2, media3=body.media3, ) except VoipMsError as exc: return _map_error(exc) guard.log_send(kind="mms", did=body.did, dst=body.dst, chars=len(body.message)) return result # --- history / retrieval --------------------------------------------------- @app.get("/sms", response_model=list[SmsRecord]) async def list_sms( request: Request, sms: Annotated[int | None, Query(description="Specific SMS id")] = None, date_from: Annotated[str | None, Query(alias="from", description="YYYY-MM-DD")] = None, date_to: Annotated[str | None, Query(alias="to", description="YYYY-MM-DD")] = None, type: Annotated[int | None, Query(description="1=received, 0=sent")] = None, did: Annotated[str | None, Query()] = None, contact: Annotated[str | None, Query()] = None, limit: Annotated[int | None, Query(ge=1)] = None, timezone: Annotated[int | None, Query(ge=-12, le=13)] = None, all_messages: Annotated[int | None, Query(description="1=SMS+MMS, 0=SMS only")] = None, ) -> list[SmsRecord]: client = _client(request) try: return await client.get_sms( sms=sms, from_=date_from, to=date_to, type=type, did=did, contact=contact, limit=limit, timezone=timezone, all_messages=all_messages, ) except VoipMsError as exc: return _map_error(exc) @app.get("/mms", response_model=list[MmsRecord]) async def list_mms( request: Request, id: Annotated[int | None, Query(description="Specific MMS id")] = None, date_from: Annotated[str | None, Query(alias="from")] = None, date_to: Annotated[str | None, Query(alias="to")] = None, type: Annotated[int | None, Query()] = None, did: Annotated[str | None, Query()] = None, contact: Annotated[str | None, Query()] = None, limit: Annotated[int | None, Query(ge=1)] = None, timezone: Annotated[int | None, Query(ge=-12, le=13)] = None, all_messages: Annotated[int | None, Query()] = None, ) -> list[MmsRecord]: client = _client(request) try: return await client.get_mms( id=id, from_=date_from, to=date_to, type=type, did=did, contact=contact, limit=limit, timezone=timezone, all_messages=all_messages, ) except VoipMsError as exc: return _map_error(exc) @app.get("/mms/{id}/media", response_model=MediaResult) async def get_mms_media( request: Request, id: int, media_as_array: Annotated[bool, Query()] = False, ) -> MediaResult: client = _client(request) try: return await client.get_media_mms(id=id, media_as_array=media_as_array) except VoipMsError as exc: return _map_error(exc) # --- delete ---------------------------------------------------------------- @app.delete("/sms/{id}", response_model=DeleteResult) async def delete_sms(request: Request, id: int) -> DeleteResult: client = _client(request) try: return await client.delete_sms(id=id) except VoipMsError as exc: return _map_error(exc) @app.delete("/mms/{id}", response_model=DeleteResult) async def delete_mms(request: Request, id: int) -> DeleteResult: client = _client(request) try: return await client.delete_mms(id=id) except VoipMsError as exc: return _map_error(exc) # --- liveness -------------------------------------------------------------- @app.get("/health", response_class=PlainTextResponse) async def health() -> str: return "ok"