"""HTTP API v2 handler — Bearer-authenticated roster PUT/DELETE. PUT /roster upsert name, extension, slack_user_id, optional email 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 from shared.roster_http import ( AuthError, SecretUnavailable, authorize_bearer, remove, upsert, validate_extension, ) 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") return validate_extension(raw) def handler(event, context): try: method, path = _route(event) logger.info("roster api %s %s", method, path) try: authorize_bearer(_header(event, "authorization")) 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: upsert(raw) 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"}) remove(extension) 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"})