Skip to content

Commit a3c3260

Browse files
authored
Merge pull request #27 from OneIdentity/feature/modular-agent-context
Switch to modular agents.md
2 parents ececce0 + c0513e2 commit a3c3260

4 files changed

Lines changed: 1041 additions & 305 deletions

File tree

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
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

Comments
 (0)