Files
sms-api-wrapper/sms/app.py
chelsea 1d9e7e0fab Add static API-key auth, dockerize service, publish façade docs
- sms/auth.py: verify_api_key dependency (Bearer/X-API-Key, SHA-256 hash
  compare via secrets.compare_digest, fail-closed 503 if no hash). All routes
  gated via a protected router; /health stays open for probes.
- config.py: new sms_api_key_hash setting (VOIPMS_SMS_API_KEY_HASH).
- Dockerfile + .dockerignore + docker-compose.yml: lean python:3.13-slim
  image; secrets injected via env_file, never baked in; host-localhost-only
  port mapping (SMS_PORT override); /health healthcheck.
- .env.example: committed template (placeholders only, no secrets).
- sms/docs/sms-facade.dokuwiki.txt: abstraction API reference (published to
  the apidoc wiki at voipms:sms-facade).
- README: Authentication section, Docker section, 401/503 error rows.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 05:09:43 +00:00

211 lines
6.9 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 APIRouter, Depends, FastAPI, Query, Request, status
from fastapi.responses import JSONResponse, PlainTextResponse
from .auth import verify_api_key
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,
)
# Every route on this router requires a valid static API key. /health is the
# only route kept on `app` directly so liveness probes stay unauthenticated.
router = APIRouter(dependencies=[Depends(verify_api_key)])
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) ----------------------------------------------------
@router.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
@router.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 ---------------------------------------------------
@router.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)
@router.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)
@router.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 ----------------------------------------------------------------
@router.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)
@router.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"
app.include_router(router)