""" CMS ACCESS API client service. Provides OAuth 2.0 token management, FHIR R4 request building, async polling, and audit logging for all CMS API operations (eligibility, alignment, reporting). """ import json import logging import os import re import threading import time import urllib.parse from datetime import datetime, timedelta, timezone from typing import Any import requests from fhirclient.models.fhirabstractbase import FHIRValidationError from fhirclient.models.operationoutcome import OperationOutcome from fhirclient.models.parameters import Parameters logger = logging.getLogger(__name__) RawResponseType = dict[str, Any] # Fallback URL path templates (kept in sync with the defaults in # access_core/settings/base.py for standalone/non-Django usage). DEFAULT_POST_PATH = "/fhir/{version}/{resource_type}/{id_suffix}/{operation}" DEFAULT_SUBMISSION_STATUS_PATH = ( "/fhir/{version}/{resource_type}/$submission-status/{submission_id}" ) _PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") def parse_fhir_resource(payload: RawResponseType | None): """Parse and validate a CMS API response payload with fhirclient models. Returns the typed resource (Parameters / OperationOutcome) or None when the payload is not a recognized FHIR resource (e.g. the {"raw": ...} text wrapper, unknown resourceType) or fails strict validation — logged at WARNING so malformed real-API bodies are visible without breaking processing; callers keep raw-dict fallbacks for those cases. Client-injected meta keys (_http_status, _complete) are stripped before validation. """ if not isinstance(payload, dict): return None body = {k: v for k, v in payload.items() if not k.startswith("_")} model = _FHIR_RESPONSE_MODELS.get(body.get("resourceType")) if model is None: return None try: return model(body) except (TypeError, ValueError, FHIRValidationError) as exc: logger.warning( f"CMS response failed fhirclient validation ({body.get('resourceType')}): {exc}" ) return None _FHIR_RESPONSE_MODELS = { "Parameters": Parameters, "OperationOutcome": OperationOutcome, } class CmsApiError(Exception): """Base exception for all CMS API errors. Attributes: message: Human-readable error description. status_code: HTTP status code (None for network-level failures). response_data: Parsed response payload or raw text wrapper. retryable: Whether this error is likely transient and worth retrying. """ retryable: bool = False def __init__( self, message: str, status_code: int | None = None, response_data: RawResponseType | None = None, ): super().__init__(message) self.status_code: int | None = status_code self.response_data: RawResponseType | None = response_data class CmsConnectionError(CmsApiError): """Network-level failure (connection refused, timeout, DNS). Retryable — transient infrastructure issue. """ retryable: bool = True class CmsServerError(CmsApiError): """Server returned 5xx status code. Retryable — likely transient backend issue. """ retryable: bool = True class CmsRateLimitError(CmsApiError): """Server returned 429 Too Many Requests. Retryable after backoff delay. """ retryable: bool = True class CmsAuthError(CmsApiError): """Authentication/authorization failure (401, 403). Non-retryable — credentials or scope misconfiguration. """ retryable: bool = False class CmsValidationError(CmsApiError): """Client sent invalid data (400 Bad Request / OperationOutcome error). Non-retryable — request payload must be corrected. """ retryable: bool = False def _raise_for_status(operation: str, resp: requests.Response) -> dict[str, Any]: """Parse response JSON and raise appropriate structured exception on error. Returns parsed payload on success (status < 400). """ response_payload: RawResponseType = {} try: response_payload = resp.json() except ValueError: response_payload = {"raw": resp.text} if resp.status_code == 401 or resp.status_code == 403: raise CmsAuthError( f"CMS API {operation} auth failed ({resp.status_code})", status_code=resp.status_code, response_data=response_payload, ) if resp.status_code == 429: raise CmsRateLimitError( f"CMS API {operation} rate limited", status_code=resp.status_code, response_data=response_payload, ) if resp.status_code >= 500: raise CmsServerError( f"CMS API {operation} server error ({resp.status_code})", status_code=resp.status_code, response_data=response_payload, ) if resp.status_code == 400: raise CmsValidationError( f"CMS API {operation} validation failed", status_code=resp.status_code, response_data=response_payload, ) if resp.status_code >= 400: raise CmsApiError( f"CMS API {operation} failed with status {resp.status_code}", status_code=resp.status_code, response_data=response_payload, ) return response_payload class CmsClient: """CMS ACCESS API client with OAuth 2.0 and FHIR R4 support.""" def __init__( self, base_url: str | None = None, client_id: str | None = None, client_secret: str | None = None, token_ttl_seconds: int | None = None, poll_interval: int | None = None, # base backoff for transient POST retries fhir_version_path: str | None = None, # e.g. "R4" or "" for non-versioned paths post_path: str | None = None, # URL path template for FHIR operations (POST) submission_status_path: str | None = None, # URL path template for $submission-status post_max_retries: int | None = None, # retry count for transient POST errors request_timeout: float | None = None, # per-request timeout in seconds ): # Prefer Django settings; fall back to env vars for standalone usage def _resolve(param, django_key, env_key, default): """Resolve config: explicit arg > Django setting > env var > default. Empty string is treated as 'not set' (falls through to next source).""" if param: # non-empty = explicitly set return param val = getattr(_django_settings, django_key, None) or os.getenv(env_key, "") return val or default def _resolve_strict(param, django_key, env_key, default): """Like _resolve but allows empty string as valid value (for fhir_version_path).""" if param is not None: return param val = getattr(_django_settings, django_key, None) if val is not None: return val env_val = os.getenv(env_key) if env_val is not None: return env_val return default try: from django.conf import settings as _django_settings self.base_url = _resolve( base_url, "CMS_API_BASE_URL", "CMS_API_BASE_URL", "https://sandbox.bcda.cms.gov", ) self.client_id = _resolve( client_id, "CMS_API_CLIENT_ID", "CMS_API_CLIENT_ID", "" ) self.client_secret = _resolve( client_secret, "CMS_API_CLIENT_SECRET", "CMS_API_CLIENT_SECRET", "" ) self.token_ttl_seconds = token_ttl_seconds or int( _resolve( None, "CMS_API_TOKEN_TTL_SECONDS", "CMS_API_TOKEN_TTL_SECONDS", "3540", ) ) self.poll_interval = poll_interval or int( _resolve( None, "CMS_POLL_INTERVAL_SECONDS", "CMS_POLL_INTERVAL_SECONDS", "5" ) ) self.fhir_version_path = _resolve_strict( fhir_version_path, "CMS_FHIR_VERSION_PATH", "CMS_FHIR_VERSION_PATH", "R4", ) self.post_path = _resolve( post_path, "CMS_API_POST_PATH", "CMS_API_POST_PATH", DEFAULT_POST_PATH ) self.submission_status_path = _resolve( submission_status_path, "CMS_API_SUBMISSION_STATUS_PATH", "CMS_API_SUBMISSION_STATUS_PATH", DEFAULT_SUBMISSION_STATUS_PATH, ) self._post_max_retries = post_max_retries or int( _resolve(None, "CMS_POST_MAX_RETRIES", "CMS_POST_MAX_RETRIES", "3") ) self.request_timeout = ( request_timeout if request_timeout is not None else float( _resolve( None, "CMS_REQUEST_TIMEOUT_SECONDS", "CMS_REQUEST_TIMEOUT_SECONDS", "60", ) ) ) except ImportError: # Django not available (standalone script) — use env vars directly self.base_url = base_url or os.getenv( "CMS_API_BASE_URL", "https://sandbox.bcda.cms.gov" ) self.client_id = client_id or os.getenv("CMS_API_CLIENT_ID", "") self.client_secret = client_secret or os.getenv("CMS_API_CLIENT_SECRET", "") self.token_ttl_seconds = token_ttl_seconds or int( os.getenv("CMS_API_TOKEN_TTL_SECONDS", "3540") ) self.poll_interval = poll_interval or int( os.getenv("CMS_POLL_INTERVAL_SECONDS", "5") ) self.fhir_version_path = fhir_version_path or os.getenv( "CMS_FHIR_VERSION_PATH", "R4" ) self.post_path = post_path or os.getenv( "CMS_API_POST_PATH", DEFAULT_POST_PATH ) self.submission_status_path = submission_status_path or os.getenv( "CMS_API_SUBMISSION_STATUS_PATH", DEFAULT_SUBMISSION_STATUS_PATH ) self._post_max_retries = post_max_retries or int( os.getenv("CMS_POST_MAX_RETRIES", "3") ) self.request_timeout = ( request_timeout if request_timeout is not None else float(os.getenv("CMS_REQUEST_TIMEOUT_SECONDS", "60")) ) self.session = requests.Session() self._token: str | None = None self._token_expires_at: datetime | None = None self._token_lock = threading.Lock() # ------------------------------------------------------------------ # OAuth 2.0 Token Management # ------------------------------------------------------------------ def _get_or_refresh_token(self) -> str | None: """Return a valid access token, refreshing if expired. Returns None if auth is disabled.""" # Skip auth when no credentials configured (local mock server mode) if not self.client_id or not self.client_secret: return None with self._token_lock: if ( self._token and self._token_expires_at and datetime.now(timezone.utc) < self._token_expires_at ): return self._token logger.info("Fetching new CMS OAuth 2.0 access token") # BCDA-style auth: POST to /auth/token with Basic Auth header # Blue Button uses patient-mediated PKCE — incompatible with server-to-server ACCESS ops token_url = f"{self.base_url}/auth/token" import base64 as _base64 creds_b64 = _base64.b64encode( f"{self.client_id}:{self.client_secret}".encode() ).decode() try: resp = self.session.post( token_url, data=b"", headers={ "Authorization": f"Basic {creds_b64}", "Accept": "application/json", }, timeout=min(self.request_timeout, 30), ) except requests.exceptions.RequestException as exc: raise CmsApiError(f"Failed to fetch CMS OAuth token: {exc}") from exc if resp.status_code != 200: raise CmsApiError( f"OAuth token request failed: {resp.text}", status_code=resp.status_code, ) token_data = resp.json() self._token = token_data.get("access_token") expires_in = token_data.get("expires_in", self.token_ttl_seconds) try: ttl = int(expires_in) except (TypeError, ValueError): logger.warning( f"Non-numeric expires_in ({expires_in!r}), using default TTL of {self.token_ttl_seconds}s" ) ttl = self.token_ttl_seconds self._token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl) if not self._token: raise CmsApiError("OAuth response missing access_token") return self._token def _auth_header(self) -> dict[str, str]: """Return Authorization header with bearer token. Empty dict if auth is disabled.""" token = self._get_or_refresh_token() if token: return {"Authorization": f"Bearer {token}"} return {} # ------------------------------------------------------------------ # FHIR R4 Operations # ------------------------------------------------------------------ def _build_url(self, pattern: str, values: dict[str, Any]) -> str: """Render a configured URL path template against base_url. {placeholders} are filled from values (URL-quoted); unknown placeholders render as empty strings and any resulting empty path segments are dropped, so one template works with or without optional parts such as the FHIR version segment or an instance id suffix. A "?" in the pattern starts the query string (e.g. the mock server's ?submissionID= form). """ # Quote placeholder values, preserving the sub-delims/unreserved chars # legal in both path segments and query strings (so "$check-eligibility" # stays readable) while still encoding anything structurally unsafe. quote_safe = "$-_.~!*'()" path_template, _, query_template = pattern.partition("?") fill: dict[str, str] = {} for name in set(_PLACEHOLDER_RE.findall(pattern)): raw = values.get(name) or "" fill[name] = urllib.parse.quote(str(raw), safe=quote_safe) if raw else "" try: rendered_path = path_template.format(**fill) rendered_query = query_template.format(**fill) if query_template else "" except (IndexError, KeyError, ValueError) as exc: raise CmsApiError( f"Malformed CMS API URL template {pattern!r}: {exc}" ) from exc segments = [seg for seg in rendered_path.split("/") if seg] url = self.base_url.rstrip("/") + "/" + "/".join(segments) if rendered_query: url += "?" + rendered_query return url def post_operation( self, resource_type: str, operation: str, parameters: dict[str, Any], id_suffix: str | None = None, extra_values: dict[str, str] | None = None, ) -> dict[str, Any]: """ Execute a FHIR R4 operation (e.g. $check-eligibility). Args: resource_type: FHIR resource type (Patient, Group, etc.) operation: Operation name with $ prefix ($check-eligibility) parameters: Parameters resource body for the operation id_suffix: Optional resource ID suffix for instance-level operations extra_values: Additional {placeholder} values for the configured CMS_API_POST_PATH template (e.g. a ?entityID= query param that a specific endpoint deployment requires). Returns: Parsed JSON response from CMS API. """ url = self._build_url( self.post_path, { "version": self.fhir_version_path, "resource_type": resource_type, "id_suffix": id_suffix or "", "operation": operation, **(extra_values or {}), }, ) headers = { **self._auth_header(), "Content-Type": "application/fhir+json", "Accept": "application/fhir+json", } # Skip double-wrapping if parameters is already a FHIR Parameters resource if ( isinstance(parameters, dict) and parameters.get("resourceType") == "Parameters" ): fhir_body = parameters else: fhir_body = self._build_parameters_resource(parameters) logger.info(f"POST {url} — operation={operation}") max_retries = getattr(self, "_post_max_retries", 3) last_status: int | None = None for attempt in range(1, max_retries + 1): try: resp = self.session.post( url, json=fhir_body, headers=headers, timeout=self.request_timeout ) except ( requests.exceptions.ConnectionError, requests.exceptions.Timeout, ) as exc: if attempt < max_retries: wait = min(self.poll_interval * (2 ** (attempt - 1)), 300) logger.warning( f"POST {operation} connection error (attempt {attempt}/{max_retries}), " f"retrying in {wait}s: {exc}" ) time.sleep(wait) continue raise CmsConnectionError( f"CMS API operation {operation} failed after {max_retries} attempts", status_code=None, response_data={"error": str(exc)}, ) from exc last_status = resp.status_code # Classify and handle response if resp.status_code == 429: if attempt < max_retries: wait = min(self.poll_interval * (2**attempt), 300) logger.warning( f"CMS API {operation} rate limited ({resp.status_code}), " f"retrying ({attempt}/{max_retries}) in {wait}s" ) time.sleep(wait) continue # Exhausted retries on rate limit — raise specific error _raise_for_status(operation, resp) if resp.status_code >= 500 and attempt < max_retries: wait = min(self.poll_interval * (2**attempt), 300) logger.warning( f"CMS API {operation} server error ({resp.status_code}), " f"retrying ({attempt}/{max_retries}) in {wait}s" ) time.sleep(wait) continue # Non-retryable or final attempt — raise structured exception response_payload = _raise_for_status(operation, resp) logger.info( f"CMS API operation {operation} returned status {resp.status_code}" ) response_payload["_http_status"] = resp.status_code return response_payload # Should not reach here, but safety net — use last known status to pick right exception type if last_status == 429: raise CmsRateLimitError( f"CMS API operation {operation} rate limited after {max_retries} attempts", status_code=last_status, response_data={}, ) raise CmsServerError( f"CMS API operation {operation} exhausted all retries (status={last_status})", status_code=last_status, response_data={}, ) def get_submission_status( self, resource_type: str, submission_id: str, ) -> dict[str, Any]: """ Perform a single $submission-status check (no looping, no sleeping). Per the CMS Operations Manual v0.9.12 this is a read-only GET: GET {base}/fhir/{version}/{resource_type}/$submission-status/{submission_id} The exact URL shape comes from the configured CMS_API_SUBMISSION_STATUS_PATH template; e.g. the local mock server takes ?submissionID=... as a query param. Response interpretation (manual "Interpreting Responses"): HTTP 202 -> still processing, no body (caller should wait and re-check) HTTP 200 -> complete; Parameters resource with the result HTTP 4xx/5xx -> error; raises CmsApiError Returns: Status response dict augmented with: _http_status: int — raw HTTP status code _complete: bool — True when the submission has a final outcome. """ operation = "$submission-status" status_url = self._build_url( self.submission_status_path, { "version": self.fhir_version_path, "resource_type": resource_type, "submission_id": submission_id, }, ) headers = {**self._auth_header(), "Accept": "application/fhir+json"} # Debug level: this fires on every polling iteration; each attempt is # also recorded as a CMSApiRequest/CMSApiResponse pair. logger.debug(f"GET {operation} submission={submission_id}") resp = self.session.get( status_url, headers=headers, timeout=self.request_timeout ) response_data: dict[str, Any] = {} try: response_data = resp.json() except ValueError: response_data = {"raw": resp.text} # HTTP 202: still processing — no result available yet. Debug level: # this fires on every polling iteration of long-running submissions. if resp.status_code == 202: logger.debug(f"Submission {submission_id} still processing (202).") return {"_http_status": 202, "_complete": False} # HTTP 4xx/5xx: error occurred — surface to caller. if resp.status_code >= 400: raise CmsApiError( f"CMS API {operation} failed with status {resp.status_code}", status_code=resp.status_code, response_data=response_data, ) # HTTP 200: per the manual the body contains a Parameters resource with # the result. If no recognizable final-result marker is present (some # implementations return intermediate bodies), treat it as still # processing rather than failing. if not self._has_final_result(response_data): logger.info( f"Submission {submission_id} returned 200 without a final result; " "treating as in progress." ) return {"_http_status": resp.status_code, "_complete": False} logger.info(f"Submission {submission_id} complete.") return {**response_data, "_http_status": resp.status_code, "_complete": True} @staticmethod def _has_final_result(response_data: dict[str, Any]) -> bool: """True when a status body carries a recognizable final result.""" parsed = parse_fhir_resource(response_data) if isinstance(parsed, Parameters): for param in parsed.parameter or []: name = (param.name or "").lower() if name == "status" and param.valueCode == "completed": return True if name in ("result", "outcome") and ( param.valueCodeableConcept is not None or param.resource is not None or bool(param.part) ): return True return False # Raw fallback — body did not validate as a FHIR resource. params = response_data.get("parameter", []) for param in params: name = (param.get("name") or "").lower() if name == "status" and param.get("valueCode") == "completed": return True if name in ("result", "outcome") and ( "valueCodeableConcept" in param or "resource" in param or "part" in param ): return True outcome = response_data.get("outcome") return isinstance(outcome, dict) and bool(outcome.get("severity")) # ------------------------------------------------------------------ # FHIR Resource Builders # ------------------------------------------------------------------ @staticmethod def _build_parameters_resource(params: dict[str, Any]) -> dict[str, Any]: """Build a FHIR R4 Parameters resource from a parameter dictionary.""" # FHIR value type keys that should be passed through as-is (not JSON-serialized) fhir_value_keys = { "valueCodeableConcept", "valueCoding", "valueInteger", "valueDate", "valueString", "valueBoolean", "valueDecimal", "valueTime", "valueInstant", "valueUri", "valueUrl", "valueCanonical", "valueId", "valueMarkdown", "valueBase64Binary", "valueUuid", } param_elements = [] for key, value in params.items(): elem: dict[str, Any] = {"name": key} if isinstance(value, dict): # Pass through FHIR value types directly (e.g., valueCodeableConcept) matching_keys = set(value.keys()) & fhir_value_keys if matching_keys: for mk in matching_keys: elem[mk] = value[mk] elif "resource" in value: elem["resource"] = value["resource"] else: elem["valueString"] = json.dumps(value) elif isinstance(value, str): elem["valueString"] = value elif isinstance(value, bool): elem["valueBoolean"] = value elif isinstance(value, (int, float)): if isinstance(value, float): elem["valueDecimal"] = value else: elem["valueInteger"] = value else: elem["valueString"] = json.dumps(value) param_elements.append(elem) return { "resourceType": "Parameters", "id": f"params-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}", "parameter": param_elements, } @staticmethod def _extract_operation_outcome(response: dict[str, Any]) -> dict[str, str] | None: """Extract OperationOutcome details from CMS API response.""" parsed = parse_fhir_resource(response) if isinstance(parsed, OperationOutcome): issues = parsed.issue or [] if not issues: return None issue = issues[0] message = issue.diagnostics or "" if not message: detail = getattr(issue, "details", None) or getattr( issue, "detail", None ) if detail and getattr(detail, "text", None): message = detail.text return { "severity": issue.severity or "", "code": issue.code or "", "message": message, } if isinstance(parsed, Parameters): for param in parsed.parameter or []: if (param.name or "") == "outcome": outcome_info: dict[str, str] = {} for part in param.part or []: val = ((part.valueString or part.valueCode) or "").strip() if val: outcome_info[part.name] = val return outcome_info # Raw fallback — body did not validate as a FHIR resource. if response.get("resourceType") == "OperationOutcome": issues = response.get("issue", []) if not issues: return None issue = issues[0] return { "severity": issue.get("severity", ""), "code": issue.get("code", ""), "message": issue.get("diagnostics") or issue.get("details", {}).get("text", ""), } for param in response.get("parameter", []): if param.get("name") == "outcome": parts = param.get("part", []) outcome_info: dict[str, str] = {} for part in parts: val = ( part.get("valueString") or part.get("valueCode") or "" ).strip() if val: outcome_info[part.get("name", "")] = val return outcome_info return None @staticmethod def _extract_submission_id(response: dict[str, Any]) -> str | None: """Extract submission-id from a FHIR response. Checks top-level params, embedded OperationOutcome, and issue diagnostics.""" import re parsed = parse_fhir_resource(response) if isinstance(parsed, Parameters): for param in parsed.parameter or []: if (param.name or "") == "submission-id": return param.valueString # A validated Parameters body cannot carry the non-standard # operationOutcome/issue wrappers below — no submission id present. return None # Raw fallbacks — body did not validate as a FHIR resource. for param in response.get("parameter", []): if param.get("name") == "submission-id": return param.get("valueString") operation_outcome = response.get("operationOutcome", {}) if operation_outcome: for param in operation_outcome.get("parameter", []): if param.get("name") == "submission-id": return param.get("valueString") # Fallback: search issue diagnostics for submissionID= (mock server format) for issue in response.get("issue", []): diag = issue.get("diagnostics", "") or "" m = re.search(r"submissionID=(sub-\d+-\d+)", diag) if m: return m.group(1) return None def describe_response_params(response: dict[str, Any]) -> str: """Non-PHI structural summary of a CMS response for error messages. Error text propagates into logs; only parameter *names* are safe to quote — never the body itself (it can echo patient resources). """ names = [ p.get("name") for p in response.get("parameter", []) if isinstance(p, dict) and p.get("name") ] return f"(response params: {names})" # ---------------------------------------------------------------------- # Process-wide shared client # ---------------------------------------------------------------------- _shared_cms_client: CmsClient | None = None _shared_cms_client_lock = threading.Lock() def get_shared_cms_client() -> CmsClient: """Return the process-wide shared CMS client (double-checked singleton). One instance per process so the OAuth token cache and the requests connection pool are reused across services instead of every call re-authenticating and leaking a fresh session. Thread-safe: token refresh is additionally guarded by CmsClient._token_lock. """ global _shared_cms_client if _shared_cms_client is None: with _shared_cms_client_lock: if _shared_cms_client is None: _shared_cms_client = CmsClient() return _shared_cms_client def reset_shared_cms_client() -> None: """Drop the shared client (test hook; forces a fresh instance next call).""" global _shared_cms_client with _shared_cms_client_lock: _shared_cms_client = None