203 lines
6.6 KiB
Python
203 lines
6.6 KiB
Python
"""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" |