2026-09-09 21:13:27 +00:00
|
|
|
"""HTTP API v2 handler — Bearer-authenticated roster PUT/DELETE.
|
|
|
|
|
|
2026-09-21 17:50:57 +00:00
|
|
|
PUT /roster upsert name, extension, slack_user_id, optional email
|
2026-09-09 21:13:27 +00:00
|
|
|
DELETE /roster/{extension} delete the row (204 even if it was already gone)
|
|
|
|
|
|
|
|
|
|
Auth is an app-level Bearer token stored in Secrets Manager. Do not log the
|
|
|
|
|
Authorization header, the token, or the request body.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import shared.sentry_init # noqa: F401
|
2026-09-21 19:13:30 +00:00
|
|
|
from shared.roster_http import (
|
|
|
|
|
AuthError,
|
|
|
|
|
SecretUnavailable,
|
|
|
|
|
authorize_bearer,
|
|
|
|
|
remove,
|
|
|
|
|
upsert,
|
|
|
|
|
validate_extension,
|
|
|
|
|
)
|
2026-09-09 21:13:27 +00:00
|
|
|
|
|
|
|
|
logger = logging.getLogger()
|
|
|
|
|
logger.setLevel(logging.INFO)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _json_response(status: int, body: dict[str, Any] | None = None) -> dict:
|
|
|
|
|
if status == 204:
|
|
|
|
|
return {"statusCode": 204, "headers": {}, "body": ""}
|
|
|
|
|
payload = {} if body is None else body
|
|
|
|
|
return {
|
|
|
|
|
"statusCode": status,
|
|
|
|
|
"headers": {"Content-Type": "application/json"},
|
|
|
|
|
"body": json.dumps(payload, separators=(",", ":")),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _header(event: dict, name: str) -> str:
|
|
|
|
|
headers = event.get("headers") or {}
|
|
|
|
|
if not isinstance(headers, dict):
|
|
|
|
|
return ""
|
|
|
|
|
target = name.lower()
|
|
|
|
|
for key, value in headers.items():
|
|
|
|
|
if str(key).lower() == target:
|
|
|
|
|
return "" if value is None else str(value)
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _route(event: dict) -> tuple[str, str]:
|
|
|
|
|
request_context = event.get("requestContext") or {}
|
|
|
|
|
http = request_context.get("http") or {}
|
|
|
|
|
method = str(http.get("method") or event.get("httpMethod") or "").upper()
|
|
|
|
|
path = str(event.get("rawPath") or http.get("path") or event.get("path") or "")
|
|
|
|
|
route_key = event.get("routeKey")
|
|
|
|
|
if isinstance(route_key, str) and " " in route_key:
|
|
|
|
|
rk_method, rk_path = route_key.split(" ", 1)
|
|
|
|
|
method = method or rk_method.upper()
|
|
|
|
|
path = path or rk_path
|
|
|
|
|
return method, path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _raw_body(event: dict) -> bytes | None:
|
|
|
|
|
body = event.get("body")
|
|
|
|
|
if body is None:
|
|
|
|
|
return b""
|
|
|
|
|
try:
|
|
|
|
|
if event.get("isBase64Encoded"):
|
|
|
|
|
if isinstance(body, bytes):
|
|
|
|
|
body = body.decode("ascii")
|
|
|
|
|
return base64.b64decode(body, validate=True)
|
|
|
|
|
if isinstance(body, bytes):
|
|
|
|
|
return body
|
|
|
|
|
return str(body).encode("utf-8")
|
|
|
|
|
except (ValueError, UnicodeError):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _delete_extension(event: dict) -> str:
|
|
|
|
|
params = event.get("pathParameters") or {}
|
|
|
|
|
if not isinstance(params, dict):
|
|
|
|
|
raise TypeError("extension")
|
|
|
|
|
raw = params.get("extension")
|
|
|
|
|
if not isinstance(raw, str):
|
|
|
|
|
raise TypeError("extension")
|
2026-09-21 19:13:30 +00:00
|
|
|
return validate_extension(raw)
|
2026-09-09 21:13:27 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def handler(event, context):
|
|
|
|
|
try:
|
|
|
|
|
method, path = _route(event)
|
|
|
|
|
logger.info("roster api %s %s", method, path)
|
|
|
|
|
try:
|
2026-09-21 19:13:30 +00:00
|
|
|
authorize_bearer(_header(event, "authorization"))
|
2026-09-09 21:13:27 +00:00
|
|
|
except AuthError:
|
|
|
|
|
return _json_response(401, {"error": "unauthorized"})
|
|
|
|
|
except SecretUnavailable:
|
|
|
|
|
return _json_response(503, {"error": "service unavailable"})
|
|
|
|
|
|
|
|
|
|
if method == "PUT" and (path == "/roster" or path.rstrip("/") == "/roster"):
|
|
|
|
|
raw = _raw_body(event)
|
|
|
|
|
if raw is None:
|
|
|
|
|
return _json_response(400, {"error": "invalid request"})
|
|
|
|
|
try:
|
2026-09-21 19:13:30 +00:00
|
|
|
upsert(raw)
|
2026-09-09 21:13:27 +00:00
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return _json_response(400, {"error": "invalid request"})
|
|
|
|
|
return _json_response(200, {"ok": True})
|
|
|
|
|
|
|
|
|
|
if method == "DELETE" and path.startswith("/roster/"):
|
|
|
|
|
try:
|
|
|
|
|
extension = _delete_extension(event)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return _json_response(400, {"error": "invalid request"})
|
2026-09-21 19:13:30 +00:00
|
|
|
remove(extension)
|
2026-09-09 21:13:27 +00:00
|
|
|
return _json_response(204)
|
|
|
|
|
|
|
|
|
|
return _json_response(405, {"error": "method not allowed"})
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.exception("roster api unexpected failure")
|
|
|
|
|
return _json_response(500, {"error": "internal error"})
|