203
sms/app.py
Normal file
203
sms/app.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user