|
| 1 | +--- |
| 2 | +name: api-patterns |
| 3 | +description: >- |
| 4 | + Use when making Safeguard API calls via SafeguardClient, working with |
| 5 | + HTTP methods, streaming, error handling, A2A credential retrieval, |
| 6 | + or managing token lifecycle. Covers method signatures, parameter |
| 7 | + conventions, and common patterns. |
| 8 | +--- |
| 9 | + |
| 10 | +# API Patterns |
| 11 | + |
| 12 | +## HTTP Methods |
| 13 | + |
| 14 | +All methods require an authenticated client (call `login()` or use a context |
| 15 | +manager first). The first two positional arguments are always `service` and |
| 16 | +`endpoint`. |
| 17 | + |
| 18 | +### GET |
| 19 | + |
| 20 | +```python |
| 21 | +client.get( |
| 22 | + service: Service, |
| 23 | + endpoint: str | None = None, |
| 24 | + *, |
| 25 | + params: Mapping[str, str] | None = None, |
| 26 | + headers: Mapping[str, str] | None = None, |
| 27 | + host: str | None = None, |
| 28 | + cert: tuple[str, str] | None = None, |
| 29 | + api_version: str | None = None, |
| 30 | +) -> requests.Response |
| 31 | +``` |
| 32 | + |
| 33 | +### POST |
| 34 | + |
| 35 | +```python |
| 36 | +client.post( |
| 37 | + service: Service, |
| 38 | + endpoint: str | None = None, |
| 39 | + *, |
| 40 | + json: JsonType | None = None, |
| 41 | + data: str | None = None, |
| 42 | + params: Mapping[str, str] | None = None, |
| 43 | + headers: Mapping[str, str] | None = None, |
| 44 | + host: str | None = None, |
| 45 | + cert: tuple[str, str] | None = None, |
| 46 | + api_version: str | None = None, |
| 47 | +) -> requests.Response |
| 48 | +``` |
| 49 | + |
| 50 | +### PUT |
| 51 | + |
| 52 | +```python |
| 53 | +client.put( |
| 54 | + service: Service, |
| 55 | + endpoint: str | None = None, |
| 56 | + *, |
| 57 | + json: JsonType | None = None, |
| 58 | + data: str | None = None, |
| 59 | + params: Mapping[str, str] | None = None, |
| 60 | + headers: Mapping[str, str] | None = None, |
| 61 | + host: str | None = None, |
| 62 | + cert: tuple[str, str] | None = None, |
| 63 | + api_version: str | None = None, |
| 64 | +) -> requests.Response |
| 65 | +``` |
| 66 | + |
| 67 | +### DELETE |
| 68 | + |
| 69 | +```python |
| 70 | +client.delete( |
| 71 | + service: Service, |
| 72 | + endpoint: str | None = None, |
| 73 | + *, |
| 74 | + params: Mapping[str, str] | None = None, |
| 75 | + headers: Mapping[str, str] | None = None, |
| 76 | + host: str | None = None, |
| 77 | + cert: tuple[str, str] | None = None, |
| 78 | + api_version: str | None = None, |
| 79 | +) -> requests.Response |
| 80 | +``` |
| 81 | + |
| 82 | +### request (low-level) |
| 83 | + |
| 84 | +```python |
| 85 | +client.request( |
| 86 | + method: HttpMethod, |
| 87 | + service: Service, |
| 88 | + endpoint: str | None = None, |
| 89 | + *, |
| 90 | + params: Mapping[str, str] | None = None, |
| 91 | + json: JsonType | None = None, |
| 92 | + data: str | None = None, |
| 93 | + headers: Mapping[str, str] | None = None, |
| 94 | + host: str | None = None, |
| 95 | + cert: tuple[str, str] | None = None, |
| 96 | + api_version: str | None = None, |
| 97 | +) -> requests.Response |
| 98 | +``` |
| 99 | + |
| 100 | +## Parameter Reference |
| 101 | + |
| 102 | +| Parameter | Type | Description | |
| 103 | +|-----------|------|-------------| |
| 104 | +| `service` | `Service` | Target API service (CORE, APPLIANCE, etc.) | |
| 105 | +| `endpoint` | `str \| None` | URL path after the service prefix (e.g., `"Users"`, `"Assets/123"`) | |
| 106 | +| `json` | `JsonType \| None` | Dict/list body — auto-sets `Content-Type: application/json` | |
| 107 | +| `data` | `str \| None` | Raw string body — no automatic content-type | |
| 108 | +| `params` | `Mapping[str, str] \| None` | Query parameters appended to URL | |
| 109 | +| `headers` | `Mapping[str, str] \| None` | Additional headers merged with defaults | |
| 110 | +| `host` | `str \| None` | Override target host (useful for clusters) | |
| 111 | +| `cert` | `tuple[str, str] \| None` | Client certificate as `(cert_file, key_file)` | |
| 112 | +| `api_version` | `str \| None` | Override API version for this request (default: `"v4"`) | |
| 113 | + |
| 114 | +### `json` vs `data` — Important Distinction |
| 115 | + |
| 116 | +- **`json=`** serializes a Python dict/list to JSON and sets |
| 117 | + `Content-Type: application/json`. Use for structured API payloads. |
| 118 | +- **`data=`** sends a raw string body with no automatic content-type. |
| 119 | + Use for pre-serialized or non-JSON payloads. |
| 120 | +- **Never pass both** — `json=` takes precedence if both are provided. |
| 121 | + |
| 122 | +## Streaming |
| 123 | + |
| 124 | +### stream — Raw streaming response |
| 125 | + |
| 126 | +```python |
| 127 | +client.stream( |
| 128 | + method: HttpMethod, service: Service, endpoint: str | None = None, |
| 129 | + *, params=..., json=..., data=..., headers=..., host=..., cert=..., api_version=..., |
| 130 | +) -> requests.Response |
| 131 | +``` |
| 132 | + |
| 133 | +Returns an **unconsumed** response with `stream=True`. Caller is responsible |
| 134 | +for iterating `response.iter_content()` or `response.iter_lines()`. |
| 135 | + |
| 136 | +### download — Stream to file |
| 137 | + |
| 138 | +```python |
| 139 | +client.download( |
| 140 | + service: Service, endpoint: str, file_path: str | Path, |
| 141 | + *, params=..., headers=..., host=..., cert=..., api_version=..., |
| 142 | + chunk_size: int = 8192, |
| 143 | +) -> int # bytes written |
| 144 | +``` |
| 145 | + |
| 146 | +### upload — Upload file or bytes |
| 147 | + |
| 148 | +```python |
| 149 | +client.upload( |
| 150 | + service: Service, endpoint: str, file_or_stream: str | Path | IO[bytes], |
| 151 | + *, content_type: str = "application/octet-stream", |
| 152 | + params=..., headers=..., host=..., cert=..., api_version=..., |
| 153 | +) -> requests.Response |
| 154 | +``` |
| 155 | + |
| 156 | +Accepts a file path (string/Path) or an open binary stream. |
| 157 | + |
| 158 | +## Safeguard API Services |
| 159 | + |
| 160 | +| Enum | URL path | Description | |
| 161 | +|---|---|---| |
| 162 | +| `Service.CORE` | `service/core` | Primary API: assets, users, policies, access requests | |
| 163 | +| `Service.APPLIANCE` | `service/appliance` | Appliance management: networking, diagnostics, backups | |
| 164 | +| `Service.NOTIFICATION` | `service/notification` | Anonymous status and notification endpoints | |
| 165 | +| `Service.A2A` | `service/a2a` | Application-to-Application credential retrieval | |
| 166 | +| `Service.EVENT` | `service/event` | SignalR event streaming | |
| 167 | +| `Service.RSTS` | `RSTS` | Embedded secure token service (authentication) | |
| 168 | + |
| 169 | +The default API version is **v4** (since Safeguard 7.0). |
| 170 | + |
| 171 | +## Error Handling |
| 172 | + |
| 173 | +### Error Hierarchy |
| 174 | + |
| 175 | +``` |
| 176 | +SafeguardError (base) |
| 177 | +├── ApiError (HTTP error responses) |
| 178 | +│ ├── AuthenticationError (401) |
| 179 | +│ ├── AuthorizationError (403) |
| 180 | +│ └── NotFoundError (404) |
| 181 | +└── TransportError (network/connection failures) |
| 182 | +``` |
| 183 | + |
| 184 | +### Error Attributes |
| 185 | + |
| 186 | +All `SafeguardError` subclasses carry: |
| 187 | + |
| 188 | +| Attribute | Type | Description | |
| 189 | +|-----------|------|-------------| |
| 190 | +| `status_code` | `int \| None` | HTTP status code | |
| 191 | +| `error_code` | `int \| None` | Safeguard-specific error code (from response `Code` field) | |
| 192 | +| `error_message` | `str \| None` | Safeguard error message (from response `Message` field) | |
| 193 | +| `response_body` | `str \| None` | Raw response body text | |
| 194 | + |
| 195 | +### Automatic Status Code Mapping |
| 196 | + |
| 197 | +`ApiError.from_response(resp)` auto-maps HTTP status codes: |
| 198 | + |
| 199 | +- `401` → `AuthenticationError` |
| 200 | +- `403` → `AuthorizationError` |
| 201 | +- `404` → `NotFoundError` |
| 202 | +- All others → `ApiError` |
| 203 | + |
| 204 | +The error message is formatted as: `"{status_code} {reason}: {method} {url}\n{body}"` |
| 205 | + |
| 206 | +### Catching Errors |
| 207 | + |
| 208 | +```python |
| 209 | +from pysafeguard import SafeguardClient, Service, NotFoundError, ApiError |
| 210 | + |
| 211 | +with SafeguardClient(...) as client: |
| 212 | + try: |
| 213 | + user = client.get(Service.CORE, "Users/99999").json() |
| 214 | + except NotFoundError: |
| 215 | + print("User not found") |
| 216 | + except ApiError as e: |
| 217 | + print(f"API error {e.status_code}: {e.error_message}") |
| 218 | +``` |
| 219 | + |
| 220 | +## Token Lifecycle |
| 221 | + |
| 222 | +### Manual refresh |
| 223 | + |
| 224 | +```python |
| 225 | +client.refresh_access_token() |
| 226 | +``` |
| 227 | + |
| 228 | +Requires the auth strategy to support refresh (`can_refresh=True`). |
| 229 | +`PasswordAuth` and `CertificateAuth` support refresh. `TokenAuth` does not. |
| 230 | +`PkceAuth` supports refresh only when no secondary password (MFA) is configured. |
| 231 | + |
| 232 | +### Check remaining lifetime |
| 233 | + |
| 234 | +```python |
| 235 | +remaining = client.token_lifetime_remaining # int | None (seconds) |
| 236 | +``` |
| 237 | + |
| 238 | +Queries `Service.APPLIANCE/SystemTime` and reads the |
| 239 | +`x-tokenlifetimeremaining` response header. |
| 240 | + |
| 241 | +### Auto-refresh |
| 242 | + |
| 243 | +```python |
| 244 | +client = SafeguardClient("host", auth=auth, auto_refresh=True) |
| 245 | +``` |
| 246 | + |
| 247 | +When enabled, every `request()`, `stream()`, and `upload()` call checks |
| 248 | +the token lifetime before executing. If the token is expired or missing, |
| 249 | +it automatically calls `refresh_access_token()`. |
| 250 | + |
| 251 | +Auto-refresh is skipped for `Service.RSTS` and `Service.APPLIANCE` requests |
| 252 | +to avoid circular refresh loops. |
| 253 | + |
| 254 | +### Logout |
| 255 | + |
| 256 | +```python |
| 257 | +client.logout() |
| 258 | +``` |
| 259 | + |
| 260 | +POSTs to `Service.CORE/Token/Logout` to invalidate the token on the |
| 261 | +appliance, then clears the local token. Errors during logout are silently |
| 262 | +ignored (best-effort). |
| 263 | + |
| 264 | +## A2A (Application-to-Application) |
| 265 | + |
| 266 | +### Context Manager Pattern |
| 267 | + |
| 268 | +```python |
| 269 | +from pysafeguard import A2AContext |
| 270 | + |
| 271 | +with A2AContext("host", "cert.pem", "key.pem", verify=False) as ctx: |
| 272 | + password = ctx.retrieve_password("my-api-key") |
| 273 | + ctx.set_password("my-api-key", "new-password") |
| 274 | + private_key = ctx.retrieve_private_key("my-api-key") |
| 275 | + secret = ctx.retrieve_api_key_secret("my-api-key") |
| 276 | +``` |
| 277 | + |
| 278 | +### Quick One-Shot Retrieval |
| 279 | + |
| 280 | +```python |
| 281 | +password = A2AContext.quick_retrieve_password( |
| 282 | + "host", "api-key", "cert.pem", "key.pem", verify=False, |
| 283 | +) |
| 284 | +private_key = A2AContext.quick_retrieve_private_key( |
| 285 | + "host", "api-key", "cert.pem", "key.pem", |
| 286 | + key_format=SshKeyFormat.OPENSSH, verify=False, |
| 287 | +) |
| 288 | +``` |
| 289 | + |
| 290 | +### A2A Methods |
| 291 | + |
| 292 | +| Method | Returns | Description | |
| 293 | +|--------|---------|-------------| |
| 294 | +| `retrieve_password(api_key)` | `HiddenString` | Retrieve managed password | |
| 295 | +| `set_password(api_key, password)` | `None` | Update managed password | |
| 296 | +| `retrieve_private_key(api_key, *, key_format=OPENSSH)` | `HiddenString` | Retrieve SSH private key | |
| 297 | +| `set_private_key(api_key, key, passphrase, *, key_format=OPENSSH)` | `None` | Update SSH private key | |
| 298 | +| `retrieve_api_key_secret(api_key)` | `JsonType` | Retrieve API key secret | |
| 299 | +| `broker_access_request(api_key, access_request)` | `str` | Submit an access request | |
| 300 | +| `get_retrievable_accounts(*, filter=None)` | `list[dict]` | List accounts (uses cert auth) | |
| 301 | + |
| 302 | +### A2A Authorization Header |
| 303 | + |
| 304 | +A2A requests use `Authorization: A2A <apiKey>` (not Bearer). |
| 305 | + |
| 306 | +### A2A Gotcha: `set_password` Content-Type |
| 307 | + |
| 308 | +`set_password` sends the password via `json=` internally. If you're calling |
| 309 | +the raw API yourself, you **must** use `Content-Type: application/json`. |
| 310 | +Using `data=` (raw string) results in **415 Unsupported Media Type**. |
| 311 | + |
| 312 | +## TLS / Certificate Verification |
| 313 | + |
| 314 | +The `verify` parameter on `SafeguardClient` and `A2AContext` accepts: |
| 315 | + |
| 316 | +- `True` (default) — use system trust store |
| 317 | +- `False` — disable TLS verification (development only) |
| 318 | +- `str` — path to a CA bundle file for custom trust |
| 319 | + |
| 320 | +```python |
| 321 | +# CA bundle (recommended for production) |
| 322 | +client = SafeguardClient("host", auth=auth, verify="/path/to/ca-bundle.pem") |
| 323 | + |
| 324 | +# Disable verification (development only) |
| 325 | +client = SafeguardClient("host", auth=auth, verify=False) |
| 326 | +``` |
| 327 | + |
| 328 | +### Environment Variables for Trust |
| 329 | + |
| 330 | +| Variable | Affects | Description | |
| 331 | +|----------|---------|-------------| |
| 332 | +| `REQUESTS_CA_BUNDLE` | All HTTP requests | CA bundle path for `requests` library | |
| 333 | +| `WEBSOCKET_CLIENT_CA_BUNDLE` | SignalR event listeners | CA bundle path for WebSocket connections | |
| 334 | + |
| 335 | +Set these when the appliance uses a certificate signed by an internal CA. |
| 336 | + |
| 337 | +## Common Patterns |
| 338 | + |
| 339 | +### GET with query parameters |
| 340 | + |
| 341 | +```python |
| 342 | +users = client.get( |
| 343 | + Service.CORE, "Users", |
| 344 | + params={"filter": "UserName eq 'admin'", "fields": "Id,UserName"}, |
| 345 | +).json() |
| 346 | +``` |
| 347 | + |
| 348 | +### POST with JSON body |
| 349 | + |
| 350 | +```python |
| 351 | +response = client.post( |
| 352 | + Service.CORE, "Users", |
| 353 | + json={"Name": "NewUser", "PrimaryAuthenticationProvider": {"Id": provider_id}}, |
| 354 | +) |
| 355 | +new_user = response.json() |
| 356 | +``` |
| 357 | + |
| 358 | +### Override API version for a single request |
| 359 | + |
| 360 | +```python |
| 361 | +response = client.get(Service.CORE, "Users", api_version="v3") |
| 362 | +``` |
| 363 | + |
| 364 | +### Override target host (cluster scenario) |
| 365 | + |
| 366 | +```python |
| 367 | +response = client.get(Service.CORE, "Users", host="other-node.example.com") |
| 368 | +``` |
| 369 | + |
| 370 | +## Async Client |
| 371 | + |
| 372 | +`AsyncSafeguardClient` mirrors the sync client exactly. All methods are |
| 373 | +`async def` with the same signatures. Use `await` on every call: |
| 374 | + |
| 375 | +```python |
| 376 | +async with AsyncSafeguardClient("host", auth=auth, verify=False) as client: |
| 377 | + users = (await client.get(Service.CORE, "Users")).json() |
| 378 | + await client.post(Service.CORE, "Users", json={"Name": "NewUser"}) |
| 379 | +``` |
| 380 | + |
| 381 | +`AsyncA2AContext` similarly mirrors `A2AContext` with async methods. |
0 commit comments