Custom clients¶
The SDK talks to Campus through two abstract interfaces in
campus_python.json_client. The default implementation is
CampusRequest
(requests-based, with trace propagation and the optional 401
auto-refresh hook), but any host can bring its own transport — the
interfaces were designed to wrap flask.testing.FlaskClient as much
as requests.
The JsonClient contract¶
A client must implement:
- the six verbs —
get,post,put,put_data(raw, non-JSON body — file/hosted-app uploads),delete,patch— each returning aJsonResponse; - authorization control: the
authorizationproperty plusset_basic_authorization(),set_bearer_authorization(), andreset_authorization()(the session managers swap tokens through these); - a
base_url.
campus_python.json_client.interface.JsonClient
¶
Bases: ABC
This class describes the public interface required from Client classes, which are used to send JSON requests.
Optional capability, not part of this abstract interface: concrete clients may support a 401 auto-refresh hook (CampusRequest.set_unauthorized_hook, issue #89) that refreshes the bearer token and retries a request once when it comes back 401. Callers must feature-detect (hasattr) rather than assume it.
Source code in campus_python/json_client/interface.py
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | |
base_url: str
instance-attribute
¶
authorization: str | None
abstractmethod
property
¶
The current Authorization header value, or None when unset.
__init__(base_url: str | None = None, *, auth: Iterable[str] | str | None = None, headers: Mapping[str, str] | None = None, **kwargs: Any)
abstractmethod
¶
Source code in campus_python/json_client/interface.py
137 138 139 140 141 142 143 144 145 | |
reset_authorization() -> None
abstractmethod
¶
Reset authorization back to client credentials from env.
Source code in campus_python/json_client/interface.py
147 148 149 | |
set_basic_authorization(client_id: str, secret: str) -> None
abstractmethod
¶
Set Basic Authorization header using a pre-encoded token.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client_id
|
str
|
Client ID. |
required |
secret
|
str
|
Client Secret. |
required |
Source code in campus_python/json_client/interface.py
156 157 158 159 160 161 162 163 | |
set_bearer_authorization(token: str) -> None
abstractmethod
¶
Set Bearer Authorization header.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str
|
Bearer token. |
required |
Source code in campus_python/json_client/interface.py
165 166 167 168 169 170 171 | |
set_default_header(name: str, value: str) -> None
¶
Set a default header sent with every request (#837).
Optional capability: the ABC default refuses, so callers can detect a client that cannot carry default headers instead of silently dropping them. Implementations should set the header on their underlying transport (e.g. the requests session).
Source code in campus_python/json_client/interface.py
173 174 175 176 177 178 179 180 181 182 183 | |
get(path: str, query: JsonDict | None = None) -> JsonResponse
abstractmethod
¶
Sends a GET request.
Source code in campus_python/json_client/interface.py
185 186 187 | |
post(path: str, json: JsonDict | None = None) -> JsonResponse
abstractmethod
¶
Sends a POST request.
Source code in campus_python/json_client/interface.py
189 190 191 | |
put(path: str, json: JsonDict | None = None, query: JsonDict | None = None) -> JsonResponse
abstractmethod
¶
Sends a PUT request.
Source code in campus_python/json_client/interface.py
193 194 195 196 197 198 199 200 | |
put_data(path: str, data: bytes | str, content_type: str | None = None, query: JsonDict | None = None) -> JsonResponse
abstractmethod
¶
Sends a PUT request with a raw (non-JSON) body.
The body is sent verbatim, with no JSON encoding; the
Content-Type header is caller-controlled. Used by file
uploads (campus.api /files by-slug), which carry raw bytes
with an explicit Content-Type. query carries URL query
parameters (e.g. the files upsert's optional display name).
Source code in campus_python/json_client/interface.py
202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | |
post_data(path: str, data: bytes | str, content_type: str | None = None, query: JsonDict | None = None) -> JsonResponse
abstractmethod
¶
Sends a POST request with a raw (non-JSON) body.
Symmetric with put_data(): the body is sent verbatim, with no
JSON encoding; the Content-Type header is caller-controlled.
Used by raw-content create routes that mint server-side ids
(campus.api timetable attachment uploads), which carry raw
bytes with an explicit Content-Type. query carries URL
query parameters (e.g. the attachment's original filename).
Source code in campus_python/json_client/interface.py
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | |
delete(path: str, json: JsonDict | None = None, query: JsonDict | None = None) -> JsonResponse
abstractmethod
¶
Sends a DELETE request.
Source code in campus_python/json_client/interface.py
237 238 239 240 241 242 243 244 | |
patch(path: str, json: Any = None, query: JsonDict | None = None) -> JsonResponse
abstractmethod
¶
Sends a PATCH request.
Source code in campus_python/json_client/interface.py
246 247 248 249 250 251 252 253 | |
The JsonResponse contract¶
Responses expose status_code, headers, text, content (bytes —
file content is binary; callers decode), and json(). Implement
raise_for_status() by delegating to the base-class mapping: it
parses the Campus error envelope (and the legacy error_code form)
into the right APIError subclass,
recovering OAuth errors from AUTH_* codes when production strips
envelope details.
campus_python.json_client.interface.JsonResponse
¶
Bases: ABC
This class describes the public interface required from Response wrappers for JSON responses.
Source code in campus_python/json_client/interface.py
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | |
status_code: int
abstractmethod
property
¶
HTTP status code of the response.
headers: dict[str, str]
abstractmethod
property
¶
Returns headers of the response as a dict.
text: str
abstractmethod
property
¶
Returns the response body as a string.
content: bytes
abstractmethod
property
¶
Returns the response body as bytes, undecoded.
File content (campus.api /files) is binary: callers decode, never this property.
__init__()
abstractmethod
¶
Source code in campus_python/json_client/interface.py
34 35 | |
json() -> Any
abstractmethod
¶
Returns the response body as JSON.
Source code in campus_python/json_client/interface.py
65 66 67 68 | |
ok() -> bool
¶
Returns True if the response status code is 2xx, False otherwise.
Source code in campus_python/json_client/interface.py
70 71 72 | |
is_client_error() -> bool
¶
Returns True if the response status code is 4xx, False otherwise.
Source code in campus_python/json_client/interface.py
74 75 76 | |
is_server_error() -> bool
¶
Returns True if the response status code is 5xx, False otherwise.
Source code in campus_python/json_client/interface.py
78 79 80 | |
raise_for_status() -> None
¶
Raise the mapped APIError subclass on a 4xx/5xx status.
Parses the Campus error envelope (and the legacy error_code form) into the registered error class for the status code; OAuth errors are recovered from AUTH_* codes when the server strips envelope details. Response headers and body ride along as error notes.
Source code in campus_python/json_client/interface.py
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | |
Optional capabilities¶
Two capabilities are deliberately not part of the ABC. Feature-detect them instead of assuming:
- Default headers —
client.set_default_header(name, value)(#837). The ABC default raisesNotImplementedError, so a client that cannot carry default headers refuses loudly rather than dropping them. - 401 auto-refresh hook —
client.set_unauthorized_hook(fn)(#89). Present onCampusRequest(and used bywith_user_session(), which feature-detects viaisinstance). The hook runs at most once per request: return the new bearer token to retry with, orNoneto surface the original 401.
The default transport¶
CampusRequest is the
requests-based client the facade builds; its optional capabilities
(set_default_header, set_unauthorized_hook) are what custom
clients may mirror.
campus_python.json_client.CampusRequest
¶
Bases: JsonClient
Campus JSON client
This client is pre-configured to handle JSON requests and responses. It provides convenience methods for common HTTP methods with JSON payloads.
Note that the client requires authorization before it can make requests. Use CampusRequest.set_basic_authorization(), CampusRequest.set_bearer_authorization(), or CampusRequest.for_user() to authorize the client, and CampusRequest.reset_authorization() to reset to client credentials
Source code in campus_python/json_client/__init__.py
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 | |
base_url = base_url or ''
instance-attribute
¶
headers: HttpHeader
property
¶
Get the currently configured headers.
authorization: str | None
property
¶
The current Authorization header value, or None when unset.
__init__(base_url: str | None, *, mode: str = 'server', headers: Mapping[str, str] | None = None, **kwargs: Any)
¶
Source code in campus_python/json_client/__init__.py
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | |
reset_authorization() -> None
¶
Reset authorization back to client credentials from env.
Source code in campus_python/json_client/__init__.py
142 143 144 145 146 147 148 | |
set_basic_authorization(client_id: str, secret: str) -> None
¶
Set Basic Authorization header using a pre-encoded token.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client_id
|
str
|
Client ID. |
required |
secret
|
str
|
Client Secret. |
required |
Source code in campus_python/json_client/__init__.py
155 156 157 158 159 160 161 162 163 164 165 166 167 | |
set_bearer_authorization(token: str) -> None
¶
Set Bearer Authorization header.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str
|
Bearer token. |
required |
Source code in campus_python/json_client/__init__.py
169 170 171 172 173 174 175 | |
set_default_header(name: str, value: str) -> None
¶
Set a default header sent with every request from this client.
Non-browser clients use this to present stable identity on API calls, e.g. X-Campus-Device (#837) for device attribution of spans. Calls made inside a host request context may still add per-request headers on top (tracing.propagation_headers).
Source code in campus_python/json_client/__init__.py
177 178 179 180 181 182 183 184 185 | |
set_unauthorized_hook(hook: Callable[[], str | None] | None) -> None
¶
Install a 401 auto-refresh hook (issue #89).
When a request comes back 401, the hook is invoked once; if it returns a bearer token, the Authorization header is refreshed and the request is retried once with it. A second 401 on the retry is returned to the caller as-is, and a hook returning None (refresh failed) leaves the original 401 response in place — failures surface exactly as they would without the hook.
The hook runs at most once per request, and never re-enters itself: requests made from inside the hook skip the hook, so a hook that calls back into this client cannot recurse.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hook
|
Callable[[], str | None] | None
|
Callable returning the new bearer token, or None to signal refresh failure. None clears the hook. |
required |
Source code in campus_python/json_client/__init__.py
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | |
get(path: str, query: JsonDict | None = None) -> JsonResponse
¶
Sends a GET request.
Source code in campus_python/json_client/__init__.py
243 244 245 246 247 248 249 250 251 252 | |
post(path: str, json: JsonDict | None = None) -> JsonResponse
¶
Sends a POST request.
Source code in campus_python/json_client/__init__.py
254 255 256 257 | |
put(path: str, json: JsonDict | None = None, query: JsonDict | None = None) -> JsonResponse
¶
Sends a PUT request.
Source code in campus_python/json_client/__init__.py
259 260 261 262 263 264 265 266 267 | |
put_data(path: str, data: bytes | str, content_type: str | None = None, query: JsonDict | None = None) -> JsonResponse
¶
Sends a PUT request with a raw body (e.g. file bytes).
Unlike put(), the body is sent verbatim with no JSON encoding; Content-Type is caller-controlled (requests' own default applies when omitted).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Request path. |
required |
data
|
bytes | str
|
Raw request body. |
required |
content_type
|
str | None
|
Content-Type header for the body, or None to leave it to the transport default. |
None
|
query
|
JsonDict | None
|
Optional URL query parameters. |
None
|
Source code in campus_python/json_client/__init__.py
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | |
post_data(path: str, data: bytes | str, content_type: str | None = None, query: JsonDict | None = None) -> JsonResponse
¶
Sends a POST request with a raw body (e.g. file bytes).
Unlike post(), the body is sent verbatim with no JSON encoding; Content-Type is caller-controlled (requests' own default applies when omitted).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str
|
Request path. |
required |
data
|
bytes | str
|
Raw request body. |
required |
content_type
|
str | None
|
Content-Type header for the body, or None to leave it to the transport default. |
None
|
query
|
JsonDict | None
|
Optional URL query parameters. |
None
|
Source code in campus_python/json_client/__init__.py
294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | |
delete(path: str, json: JsonDict | None = None, query: JsonDict | None = None) -> JsonResponse
¶
Sends a DELETE request.
Source code in campus_python/json_client/__init__.py
319 320 321 322 323 324 325 326 327 | |
patch(path: str, json: Any = None, query: JsonDict | None = None) -> JsonResponse
¶
Sends a PATCH request.
Source code in campus_python/json_client/__init__.py
329 330 331 332 333 334 335 336 337 | |
Building one¶
The interfaces exist so hosts can point the SDK at an in-process WSGI app (unit/integration tests without a network) or an exotic transport. The shape:
from campus_python.json_client.interface import JsonClient, JsonResponse
class FlaskTestClientResponse(JsonResponse):
def __init__(self, response):
self._response = response # werkzeug.test.TestResponse
# implement status_code / headers / text / content / json()
# and reuse JsonResponse.raise_for_status (do not override)
class FlaskTestClient(JsonClient):
def __init__(self, test_client, base_url="https://campus.test"):
self.base_url = base_url
self._client = test_client
# implement the six verbs as test_client.open(...) calls,
# plus the authorization methods
Then construct the service roots directly against it:
from campus_python.api.v1 import ApiRoot
api = ApiRoot(json_client=FlaskTestClient(flask_app.test_client()))
api.assignments.list()
The repo's own test suite stays one level simpler: resource unit tests
drive Mock clients through the shared factories in tests/support.py
(make_api, make_auth, json_response), pinning recorded calls
rather than running a real WSGI stack.