ahs-cti-api-sdk 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ahs_cti/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """AHS CTI Platform — Python SDK."""
2
+
3
+ from .client import AsyncCtiClient, CtiClient
4
+ from ._http import ClientConfig
5
+ from .exceptions import CtiApiError, CtiAuthError, CtiError, CtiRetryExhaustedError
6
+ from .models import DialResult, EndCallResult
7
+
8
+ __version__ = "0.5.0"
9
+
10
+ __all__ = [
11
+ # Clients
12
+ "CtiClient",
13
+ "AsyncCtiClient",
14
+ # Configuration
15
+ "ClientConfig",
16
+ # Models
17
+ "DialResult",
18
+ "EndCallResult",
19
+ # Exceptions
20
+ "CtiError",
21
+ "CtiAuthError",
22
+ "CtiApiError",
23
+ "CtiRetryExhaustedError",
24
+ ]
ahs_cti/_auth.py ADDED
@@ -0,0 +1,236 @@
1
+ """Authentication strategies — sync and async, with shared-transport injection.
2
+
3
+ Design
4
+ ------
5
+ Auth strategies satisfy the ``SyncAuthStrategy`` / ``AsyncAuthStrategy``
6
+ protocols — they expose only ``get_headers()``. The HTTP transport is
7
+ injected so that the same ``httpx.Client`` (and its ``verify`` / ``timeout``
8
+ settings) is used for both login calls and regular API calls.
9
+
10
+ Adding a new auth method (e.g. OAuth2) requires only implementing the
11
+ protocol — no changes to ``_http.py`` or ``client.py``.
12
+ """
13
+
14
+ import logging
15
+ import time
16
+ from datetime import datetime, timezone
17
+ from typing import Dict, Optional
18
+
19
+ import httpx
20
+
21
+ from .exceptions import CtiAuthError
22
+
23
+ logger = logging.getLogger("ahs_cti.auth")
24
+
25
+ _MIME_JSON = "application/json"
26
+ _EXPIRY_BUFFER_SECS = 60
27
+ _DEFAULT_TOKEN_TTL = 55 * 60
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Protocols (open for extension, no import of concrete classes needed)
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ class SyncAuthStrategy:
36
+ """Interface for synchronous authentication strategies.
37
+
38
+ Any class that implements ``get_headers() -> Dict[str, str]``
39
+ satisfies this interface and can be used with ``_HttpClient``.
40
+ """
41
+
42
+ def get_headers(self) -> Dict[str, str]:
43
+ raise NotImplementedError
44
+
45
+
46
+ class AsyncAuthStrategy:
47
+ """Interface for asynchronous authentication strategies.
48
+
49
+ Any class that implements ``async get_headers() -> Dict[str, str]``
50
+ satisfies this interface and can be used with ``_AsyncHttpClient``.
51
+ """
52
+
53
+ async def get_headers(self) -> Dict[str, str]:
54
+ raise NotImplementedError
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Helpers
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def _build_headers(auth_headers: Dict[str, str]) -> Dict[str, str]:
63
+ return {**auth_headers, "Content-Type": _MIME_JSON, "Accept": _MIME_JSON}
64
+
65
+
66
+ def _parse_expiry(expiry_str: Optional[str]) -> float:
67
+ if expiry_str:
68
+ try:
69
+ dt = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
70
+ return dt.astimezone(timezone.utc).timestamp()
71
+ except ValueError:
72
+ pass
73
+ return time.time() + _DEFAULT_TOKEN_TTL
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Sync strategies
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ class _ApiKeyAuth(SyncAuthStrategy):
82
+ """Static API-key auth. Headers are built in memory — no network calls."""
83
+
84
+ def __init__(self, api_key: str, tenant_id: Optional[int] = None) -> None:
85
+ self._api_key = api_key
86
+ self._tenant_id = tenant_id
87
+
88
+ def get_headers(self) -> Dict[str, str]:
89
+ auth: Dict[str, str] = {"X-API-Key": self._api_key}
90
+ if self._tenant_id is not None:
91
+ auth["X-Tenant-ID"] = str(self._tenant_id)
92
+ return _build_headers(auth)
93
+
94
+
95
+ class _CredentialAuth(SyncAuthStrategy):
96
+ """Credential-based auth — lazy login, auto-refresh 60 s before expiry.
97
+
98
+ The caller injects the shared ``httpx.Client`` so that SSL and timeout
99
+ settings are consistent between the login call and regular API calls.
100
+
101
+ Args:
102
+ login_url: Full URL of the login endpoint.
103
+ user_id: Agent username.
104
+ password: Agent password.
105
+ http_client: Shared ``httpx.Client`` instance (borrowed, not owned).
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ login_url: str,
111
+ user_id: str,
112
+ password: str,
113
+ http_client: httpx.Client,
114
+ ) -> None:
115
+ self._login_url = login_url
116
+ self._user_id = user_id
117
+ self._password = password
118
+ self._http_client = http_client
119
+ self._access_token: Optional[str] = None
120
+ self._token_expiry: float = 0.0
121
+
122
+ def get_headers(self) -> Dict[str, str]:
123
+ if not self._is_token_valid():
124
+ self._login()
125
+ return _build_headers({"Authorization": f"Bearer {self._access_token}"})
126
+
127
+ def _is_token_valid(self) -> bool:
128
+ return (
129
+ self._access_token is not None
130
+ and time.time() < self._token_expiry - _EXPIRY_BUFFER_SECS
131
+ )
132
+
133
+ def _login(self) -> None:
134
+ logger.debug("ahs_cti: acquiring token for user_id=%s", self._user_id)
135
+ try:
136
+ resp = self._http_client.post(
137
+ self._login_url,
138
+ json={"userId": self._user_id, "password": self._password},
139
+ )
140
+ except httpx.RequestError as exc:
141
+ raise CtiAuthError(f"Network error during login: {exc}") from exc
142
+ if resp.status_code != 200:
143
+ raise CtiAuthError(
144
+ f"Login failed (HTTP {resp.status_code}). "
145
+ "Verify user_id, password, and base_url."
146
+ )
147
+ data = resp.json()
148
+ token = data.get("accessToken")
149
+ if not token:
150
+ raise CtiAuthError("Login response did not contain an accessToken.")
151
+ self._access_token = token
152
+ self._token_expiry = _parse_expiry(data.get("expiration"))
153
+ logger.debug("ahs_cti: token acquired, expires at %s", self._token_expiry)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Async strategies
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ class _AsyncApiKeyAuth(SyncAuthStrategy):
162
+ """API-key auth for use with the async client.
163
+
164
+ Satisfies ``SyncAuthStrategy`` — header building is pure in-memory,
165
+ so no coroutine is needed. ``_AsyncHttpClient`` resolves headers via
166
+ ``_resolve_headers()`` which handles both sync and async strategies.
167
+ """
168
+
169
+ def __init__(self, api_key: str, tenant_id: Optional[int] = None) -> None:
170
+ self._inner = _ApiKeyAuth(api_key=api_key, tenant_id=tenant_id)
171
+
172
+ def get_headers(self) -> Dict[str, str]:
173
+ return self._inner.get_headers()
174
+
175
+
176
+ class _AsyncCredentialAuth(AsyncAuthStrategy):
177
+ """Async credential auth — lazy login with asyncio.Lock to prevent races.
178
+
179
+ Args:
180
+ login_url: Full URL of the login endpoint.
181
+ user_id: Agent username.
182
+ password: Agent password.
183
+ http_client: Shared ``httpx.AsyncClient`` instance (borrowed, not owned).
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ login_url: str,
189
+ user_id: str,
190
+ password: str,
191
+ http_client: httpx.AsyncClient,
192
+ ) -> None:
193
+ self._login_url = login_url
194
+ self._user_id = user_id
195
+ self._password = password
196
+ self._http_client = http_client
197
+ self._access_token: Optional[str] = None
198
+ self._token_expiry: float = 0.0
199
+ self._lock: Optional["asyncio.Lock"] = None # created lazily inside event loop
200
+
201
+ async def get_headers(self) -> Dict[str, str]:
202
+ import asyncio
203
+ if self._lock is None:
204
+ self._lock = asyncio.Lock()
205
+ async with self._lock:
206
+ if not self._is_token_valid():
207
+ await self._login()
208
+ return _build_headers({"Authorization": f"Bearer {self._access_token}"})
209
+
210
+ def _is_token_valid(self) -> bool:
211
+ return (
212
+ self._access_token is not None
213
+ and time.time() < self._token_expiry - _EXPIRY_BUFFER_SECS
214
+ )
215
+
216
+ async def _login(self) -> None:
217
+ logger.debug("ahs_cti: async acquiring token for user_id=%s", self._user_id)
218
+ try:
219
+ resp = await self._http_client.post(
220
+ self._login_url,
221
+ json={"userId": self._user_id, "password": self._password},
222
+ )
223
+ except httpx.RequestError as exc:
224
+ raise CtiAuthError(f"Network error during login: {exc}") from exc
225
+ if resp.status_code != 200:
226
+ raise CtiAuthError(
227
+ f"Login failed (HTTP {resp.status_code}). "
228
+ "Verify user_id, password, and base_url."
229
+ )
230
+ data = resp.json()
231
+ token = data.get("accessToken")
232
+ if not token:
233
+ raise CtiAuthError("Login response did not contain an accessToken.")
234
+ self._access_token = token
235
+ self._token_expiry = _parse_expiry(data.get("expiration"))
236
+ logger.debug("ahs_cti: async token acquired, expires at %s", self._token_expiry)
ahs_cti/_endpoints.py ADDED
@@ -0,0 +1,56 @@
1
+ """Central API endpoint registry.
2
+
3
+ All paths are versioned under API_VERSION. To upgrade the entire SDK
4
+ to a new API version, change API_VERSION here — no other file needs editing.
5
+
6
+ Usage::
7
+
8
+ from ._endpoints import Endpoints
9
+ self._http.post(Endpoints.DIAL, body)
10
+ """
11
+
12
+ API_VERSION = "v1"
13
+ _BASE = f"/api/{API_VERSION}/cti"
14
+
15
+
16
+ class Endpoints:
17
+ """All CTI platform API endpoint paths."""
18
+
19
+ # ── Calls ─────────────────────────────────────────────────────────────────
20
+ DIAL = f"{_BASE}/calls/dial"
21
+ HANGUP = f"{_BASE}/calls/hangup"
22
+ ANSWER = f"{_BASE}/calls/{{call_id}}/answer"
23
+ FETCH_DETAILS = f"{_BASE}/calls/fetch-call-details"
24
+ ACTIVE_CALLS = f"{_BASE}/calls/active"
25
+
26
+ # ── Hold / Mute / DTMF ───────────────────────────────────────────────────
27
+ HOLD = f"{_BASE}/calls/hold"
28
+ MUTE = f"{_BASE}/calls/mute"
29
+ DTMF = f"{_BASE}/calls/{{call_id}}/dtmf"
30
+
31
+ # ── Transfer ──────────────────────────────────────────────────────────────
32
+ TRANSFER = f"{_BASE}/calls/{{call_id}}/transfer"
33
+ TRANSFER_CALL = f"{_BASE}/calls/transfer-call"
34
+ TRANSFER_CANCEL = f"{_BASE}/calls/transfer/cancel"
35
+ TRANSFER_COMPLETE = f"{_BASE}/calls/transfer/complete"
36
+
37
+ # ── Conference ────────────────────────────────────────────────────────────
38
+ CONFERENCE_ADD = f"{_BASE}/calls/conference/add"
39
+ CONFERENCE_MERGE = f"{_BASE}/calls/conference/merge"
40
+
41
+ # ── Agent ─────────────────────────────────────────────────────────────────
42
+ AGENT_REGISTER = f"{_BASE}/agent/register"
43
+ AGENT_UNREGISTER = f"{_BASE}/agent/unregister"
44
+ AGENT_CONFIG = f"{_BASE}/agent/config"
45
+
46
+ # ── Supervisor ────────────────────────────────────────────────────────────
47
+ SUPERVISOR_LISTEN = f"{_BASE}/supervisor/listen"
48
+ SUPERVISOR_LEAVE = f"{_BASE}/supervisor/leave"
49
+ SUPERVISOR_MODE = f"{_BASE}/supervisor/mode"
50
+
51
+ # ── Data / Lookup ─────────────────────────────────────────────────────────
52
+ DISPOSITIONS = f"{_BASE}/calls/dispositions"
53
+ TRANSFER_TO_DETAILS = f"{_BASE}/transfer-to-details"
54
+ USERS = f"{_BASE}/users"
55
+ HOSPITAL_SERVICES = f"{_BASE}/hospital/services"
56
+ BREAKS = f"{_BASE}/breaks"
ahs_cti/_http.py ADDED
@@ -0,0 +1,277 @@
1
+ """HTTP transport — sync and async, with retry and shared client injection.
2
+
3
+ Design
4
+ ------
5
+ - ``ClientConfig`` groups all transport settings in one immutable object.
6
+ - Both ``_HttpClient`` and ``_AsyncHttpClient`` receive an externally created
7
+ ``httpx.Client`` / ``httpx.AsyncClient`` so that SSL and timeout settings
8
+ are consistent across login calls and regular API calls.
9
+ - Retry uses exponential backoff on 5xx responses and transient network errors.
10
+ 4xx responses are never retried (deterministic client errors).
11
+ """
12
+
13
+ import asyncio
14
+ import inspect
15
+ import logging
16
+ import time
17
+ from dataclasses import dataclass
18
+ from typing import Any, Dict, Optional, Union
19
+
20
+ import httpx
21
+
22
+ from ._auth import AsyncAuthStrategy, SyncAuthStrategy
23
+ from .exceptions import CtiApiError, CtiAuthError, CtiRetryExhaustedError
24
+
25
+ logger = logging.getLogger("ahs_cti.http")
26
+
27
+ _RETRYABLE_STATUS_CODES = frozenset({500, 502, 503, 504})
28
+ _RETRYABLE_EXCEPTIONS = (
29
+ httpx.ConnectError,
30
+ httpx.TimeoutException,
31
+ httpx.RemoteProtocolError,
32
+ )
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Configuration
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ClientConfig:
42
+ """Immutable transport configuration for ``CtiClient`` and ``AsyncCtiClient``.
43
+
44
+ Args:
45
+ base_url: Root URL of the CTI platform (no trailing slash).
46
+ Example: ``"https://cti.example.com"``
47
+ timeout: HTTP request timeout in seconds. Applied to all requests
48
+ including the auth login call. Defaults to ``30.0``.
49
+ verify_ssl: Whether to verify the server's TLS certificate.
50
+ Set to ``False`` for servers with self-signed certificates.
51
+ Defaults to ``True``.
52
+ max_retries: Maximum number of retries after the first attempt for
53
+ 5xx responses and transient connection errors. ``0`` disables
54
+ retries entirely. Defaults to ``3``.
55
+ """
56
+
57
+ base_url: str
58
+ timeout: float = 30.0
59
+ verify_ssl: bool = True
60
+ max_retries: int = 3
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Response parsing
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def _parse_response(resp: httpx.Response) -> Dict[str, Any]:
69
+ if resp.status_code == 401:
70
+ raise CtiAuthError(
71
+ "Unauthorized — the token or API key was rejected. "
72
+ "It may have been revoked or is expired."
73
+ )
74
+ if resp.status_code == 403:
75
+ raise CtiApiError(
76
+ "Forbidden — this account does not have permission for this action.",
77
+ status_code=403,
78
+ )
79
+ if resp.status_code >= 400:
80
+ try:
81
+ detail = resp.json()
82
+ except Exception:
83
+ detail = {"raw": resp.text}
84
+ raise CtiApiError(
85
+ "The platform returned an error.",
86
+ status_code=resp.status_code,
87
+ response=detail,
88
+ )
89
+ try:
90
+ return resp.json()
91
+ except Exception:
92
+ return {"raw": resp.text}
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Auth header resolution — handles both sync and async strategies
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ async def _resolve_headers(
101
+ auth: Union[SyncAuthStrategy, AsyncAuthStrategy],
102
+ ) -> Dict[str, str]:
103
+ """Call ``auth.get_headers()`` whether it is sync or async."""
104
+ result = auth.get_headers()
105
+ if inspect.isawaitable(result):
106
+ return await result
107
+ return result # type: ignore[return-value]
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Sync client
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ class _HttpClient:
116
+ """Sync HTTP wrapper — injects auth, retries on transient failures.
117
+
118
+ Args:
119
+ config: Transport configuration.
120
+ auth: Authentication strategy (sync).
121
+ http_client: Shared ``httpx.Client`` instance to use for all requests.
122
+ """
123
+
124
+ def __init__(
125
+ self,
126
+ config: ClientConfig,
127
+ auth: SyncAuthStrategy,
128
+ http_client: httpx.Client,
129
+ ) -> None:
130
+ self._config = config
131
+ self._auth = auth
132
+ self._client = http_client
133
+
134
+ def post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
135
+ url = f"{self._config.base_url.rstrip('/')}{path}"
136
+ logger.debug("ahs_cti: POST %s", path)
137
+ resp = self._execute_with_retry("post", url, json=body)
138
+ return _parse_response(resp)
139
+
140
+ def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
141
+ url = f"{self._config.base_url.rstrip('/')}{path}"
142
+ logger.debug("ahs_cti: GET %s", path)
143
+ resp = self._execute_with_retry("get", url, params=params or {})
144
+ return _parse_response(resp)
145
+
146
+ def _execute_with_retry(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
147
+ last_exc: Optional[Exception] = None
148
+ for attempt in range(self._config.max_retries + 1):
149
+ if attempt > 0:
150
+ wait = min(2 ** attempt, 30)
151
+ logger.warning(
152
+ "ahs_cti: retrying %s %s (attempt %d/%d) in %ds",
153
+ method.upper(), url, attempt, self._config.max_retries, wait,
154
+ )
155
+ time.sleep(wait)
156
+ try:
157
+ headers = self._auth.get_headers()
158
+ resp = getattr(self._client, method)(url, headers=headers, **kwargs)
159
+ logger.debug("ahs_cti: %s %s → %d", method.upper(), url, resp.status_code)
160
+ if resp.status_code in _RETRYABLE_STATUS_CODES:
161
+ last_exc = CtiApiError(
162
+ f"Server error on {url}",
163
+ status_code=resp.status_code,
164
+ )
165
+ continue
166
+ return resp
167
+ except _RETRYABLE_EXCEPTIONS as exc:
168
+ last_exc = exc
169
+ continue
170
+ except httpx.RequestError as exc:
171
+ raise CtiApiError(f"Network error calling {url}: {exc}") from exc
172
+
173
+ logger.error(
174
+ "ahs_cti: all %d attempts exhausted for %s %s",
175
+ self._config.max_retries + 1, method.upper(), url,
176
+ )
177
+ raise CtiRetryExhaustedError(
178
+ f"Request to {url} failed after {self._config.max_retries + 1} attempt(s).",
179
+ attempts=self._config.max_retries + 1,
180
+ last_exc=last_exc,
181
+ ) from last_exc
182
+
183
+ def close(self) -> None:
184
+ self._client.close()
185
+
186
+ def __enter__(self) -> "_HttpClient":
187
+ return self
188
+
189
+ def __exit__(self, *_: Any) -> None:
190
+ self.close()
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Async client
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ class _AsyncHttpClient:
199
+ """Async HTTP wrapper — injects auth, retries on transient failures.
200
+
201
+ Args:
202
+ config: Transport configuration.
203
+ auth: Authentication strategy (sync or async).
204
+ http_client: Shared ``httpx.AsyncClient`` instance to use for all requests.
205
+ """
206
+
207
+ def __init__(
208
+ self,
209
+ config: ClientConfig,
210
+ auth: Union[SyncAuthStrategy, AsyncAuthStrategy],
211
+ http_client: httpx.AsyncClient,
212
+ ) -> None:
213
+ self._config = config
214
+ self._auth = auth
215
+ self._client = http_client
216
+
217
+ async def post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
218
+ url = f"{self._config.base_url.rstrip('/')}{path}"
219
+ logger.debug("ahs_cti: async POST %s", path)
220
+ resp = await self._execute_with_retry("post", url, json=body)
221
+ return _parse_response(resp)
222
+
223
+ async def get(
224
+ self, path: str, params: Optional[Dict[str, Any]] = None
225
+ ) -> Dict[str, Any]:
226
+ url = f"{self._config.base_url.rstrip('/')}{path}"
227
+ logger.debug("ahs_cti: async GET %s", path)
228
+ resp = await self._execute_with_retry("get", url, params=params or {})
229
+ return _parse_response(resp)
230
+
231
+ async def _execute_with_retry(
232
+ self, method: str, url: str, **kwargs: Any
233
+ ) -> httpx.Response:
234
+ last_exc: Optional[Exception] = None
235
+ for attempt in range(self._config.max_retries + 1):
236
+ if attempt > 0:
237
+ wait = min(2 ** attempt, 30)
238
+ logger.warning(
239
+ "ahs_cti: retrying %s %s (attempt %d/%d) in %ds",
240
+ method.upper(), url, attempt, self._config.max_retries, wait,
241
+ )
242
+ await asyncio.sleep(wait)
243
+ try:
244
+ headers = await _resolve_headers(self._auth)
245
+ resp = await getattr(self._client, method)(url, headers=headers, **kwargs)
246
+ logger.debug("ahs_cti: %s %s → %d", method.upper(), url, resp.status_code)
247
+ if resp.status_code in _RETRYABLE_STATUS_CODES:
248
+ last_exc = CtiApiError(
249
+ f"Server error on {url}",
250
+ status_code=resp.status_code,
251
+ )
252
+ continue
253
+ return resp
254
+ except _RETRYABLE_EXCEPTIONS as exc:
255
+ last_exc = exc
256
+ continue
257
+ except httpx.RequestError as exc:
258
+ raise CtiApiError(f"Network error calling {url}: {exc}") from exc
259
+
260
+ logger.error(
261
+ "ahs_cti: all %d attempts exhausted for %s %s",
262
+ self._config.max_retries + 1, method.upper(), url,
263
+ )
264
+ raise CtiRetryExhaustedError(
265
+ f"Request to {url} failed after {self._config.max_retries + 1} attempt(s).",
266
+ attempts=self._config.max_retries + 1,
267
+ last_exc=last_exc,
268
+ ) from last_exc
269
+
270
+ async def aclose(self) -> None:
271
+ await self._client.aclose()
272
+
273
+ async def __aenter__(self) -> "_AsyncHttpClient":
274
+ return self
275
+
276
+ async def __aexit__(self, *_: Any) -> None:
277
+ await self.aclose()