pytest-api-core 1.0.1__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.
- pytest_api_core/__init__.py +20 -0
- pytest_api_core/assertions/__init__.py +3 -0
- pytest_api_core/assertions/response_assertions.py +350 -0
- pytest_api_core/auth/__init__.py +8 -0
- pytest_api_core/auth/auth_handlers.py +156 -0
- pytest_api_core/client/__init__.py +4 -0
- pytest_api_core/client/api_client.py +183 -0
- pytest_api_core/client/api_response.py +101 -0
- pytest_api_core/config/__init__.py +3 -0
- pytest_api_core/config/base_settings.py +54 -0
- pytest_api_core/config/config_manager.py +156 -0
- pytest_api_core/config/env_loader.py +110 -0
- pytest_api_core/fixtures/__init__.py +9 -0
- pytest_api_core/fixtures/api_fixtures.py +133 -0
- pytest_api_core/plugin.py +125 -0
- pytest_api_core/reporters/__init__.py +3 -0
- pytest_api_core/reporters/html_reporter.py +245 -0
- pytest_api_core/reporters/templates/report.html +469 -0
- pytest_api_core-1.0.1.dist-info/METADATA +314 -0
- pytest_api_core-1.0.1.dist-info/RECORD +24 -0
- pytest_api_core-1.0.1.dist-info/WHEEL +5 -0
- pytest_api_core-1.0.1.dist-info/entry_points.txt +2 -0
- pytest_api_core-1.0.1.dist-info/licenses/LICENSE +21 -0
- pytest_api_core-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pytest-api-core: Reusable API automation framework for pytest.
|
|
3
|
+
Auto-registered as a pytest plugin via entry_points["pytest11"].
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pytest_api_core.client.api_client import APIClient
|
|
7
|
+
from pytest_api_core.client.api_response import APIResponse
|
|
8
|
+
from pytest_api_core.assertions.response_assertions import assert_that
|
|
9
|
+
from pytest_api_core.auth.auth_handlers import BearerAuth, BasicAuth, APIKeyAuth, OAuth2ClientCredentials
|
|
10
|
+
|
|
11
|
+
__version__ = "1.0.0"
|
|
12
|
+
__all__ = [
|
|
13
|
+
"APIClient",
|
|
14
|
+
"APIResponse",
|
|
15
|
+
"assert_that",
|
|
16
|
+
"BearerAuth",
|
|
17
|
+
"BasicAuth",
|
|
18
|
+
"APIKeyAuth",
|
|
19
|
+
"OAuth2ClientCredentials",
|
|
20
|
+
]
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fluent (chainable) response assertion API.
|
|
3
|
+
|
|
4
|
+
Usage
|
|
5
|
+
-----
|
|
6
|
+
from pytest_api_core.assertions import assert_that
|
|
7
|
+
|
|
8
|
+
assert_that(response).status_is(200)
|
|
9
|
+
assert_that(response).status_is(200).has_key("id").json_path("$.name").equals("Alice")
|
|
10
|
+
assert_that(response).content_type_contains("application/json")
|
|
11
|
+
assert_that(response).response_time_under(500) # ms
|
|
12
|
+
|
|
13
|
+
Every assertion method returns *self* so calls can be chained freely.
|
|
14
|
+
A clear AssertionError with context is raised on failure.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pytest_api_core.client.api_response import APIResponse
|
|
24
|
+
|
|
25
|
+
_assert_log = logging.getLogger("pytest_api_core.assertions")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _emit(name: str, passed: bool, **detail: Any) -> None:
|
|
29
|
+
"""Emit a machine-readable sentinel line captured by HTMLReporter."""
|
|
30
|
+
_assert_log.debug(
|
|
31
|
+
"__API_ASSERT__ %s",
|
|
32
|
+
json.dumps({"name": name, "passed": passed, **detail}, separators=(",", ":")),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ResponseAssertions:
|
|
37
|
+
"""Chainable assertion wrapper for APIResponse objects."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, response: APIResponse) -> None:
|
|
40
|
+
self._response = response
|
|
41
|
+
# jsonpath-ng is optional; fall back to simple key lookup if absent
|
|
42
|
+
self._json_path_value: Any = _UNSET
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Status code
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def status_is(self, expected: int) -> "ResponseAssertions":
|
|
49
|
+
"""Assert the HTTP status code equals *expected*."""
|
|
50
|
+
actual = self._response.status_code
|
|
51
|
+
passed = actual == expected
|
|
52
|
+
_emit("status_is", passed, expected=expected, actual=actual)
|
|
53
|
+
if not passed:
|
|
54
|
+
raise AssertionError(
|
|
55
|
+
f"Expected status {expected}, got {actual}.\n"
|
|
56
|
+
f" URL: {self._response.url}\n"
|
|
57
|
+
f" Body: {self._response.response_body_text()[:500]}"
|
|
58
|
+
)
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def status_in(self, *expected: int) -> "ResponseAssertions":
|
|
62
|
+
"""Assert the status code is one of *expected*."""
|
|
63
|
+
actual = self._response.status_code
|
|
64
|
+
passed = actual in expected
|
|
65
|
+
_emit("status_in", passed, expected=list(expected), actual=actual)
|
|
66
|
+
if not passed:
|
|
67
|
+
raise AssertionError(
|
|
68
|
+
f"Expected status in {expected}, got {actual}.\n"
|
|
69
|
+
f" URL: {self._response.url}"
|
|
70
|
+
)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def is_success(self) -> "ResponseAssertions":
|
|
74
|
+
"""Assert 2xx status code."""
|
|
75
|
+
actual = self._response.status_code
|
|
76
|
+
passed = 200 <= actual < 300
|
|
77
|
+
_emit("is_success", passed, actual=actual)
|
|
78
|
+
if not passed:
|
|
79
|
+
raise AssertionError(
|
|
80
|
+
f"Expected 2xx status, got {actual}.\n"
|
|
81
|
+
f" URL: {self._response.url}\n"
|
|
82
|
+
f" Body: {self._response.response_body_text()[:500]}"
|
|
83
|
+
)
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def is_client_error(self) -> "ResponseAssertions":
|
|
87
|
+
"""Assert 4xx status code."""
|
|
88
|
+
actual = self._response.status_code
|
|
89
|
+
if not (400 <= actual < 500):
|
|
90
|
+
raise AssertionError(f"Expected 4xx status, got {actual}.")
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def is_server_error(self) -> "ResponseAssertions":
|
|
94
|
+
"""Assert 5xx status code."""
|
|
95
|
+
actual = self._response.status_code
|
|
96
|
+
if not (500 <= actual < 600):
|
|
97
|
+
raise AssertionError(f"Expected 5xx status, got {actual}.")
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Headers
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def has_header(self, name: str) -> "ResponseAssertions":
|
|
105
|
+
"""Assert a response header with *name* is present (case-insensitive)."""
|
|
106
|
+
if name.lower() not in {k.lower() for k in self._response.headers}:
|
|
107
|
+
raise AssertionError(
|
|
108
|
+
f"Expected header '{name}' to be present.\n"
|
|
109
|
+
f" Headers: {dict(self._response.headers)}"
|
|
110
|
+
)
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def header_equals(self, name: str, expected: str) -> "ResponseAssertions":
|
|
114
|
+
"""Assert header *name* equals *expected* (case-insensitive name match)."""
|
|
115
|
+
actual = self._response.headers.get(name)
|
|
116
|
+
if actual != expected:
|
|
117
|
+
raise AssertionError(
|
|
118
|
+
f"Expected header '{name}' = {expected!r}, got {actual!r}."
|
|
119
|
+
)
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def content_type_contains(self, fragment: str) -> "ResponseAssertions":
|
|
123
|
+
"""Assert the Content-Type header contains *fragment*."""
|
|
124
|
+
ct = self._response.headers.get("Content-Type", "")
|
|
125
|
+
if fragment.lower() not in ct.lower():
|
|
126
|
+
raise AssertionError(
|
|
127
|
+
f"Expected Content-Type to contain {fragment!r}, got {ct!r}."
|
|
128
|
+
)
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# JSON body — top-level keys
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def has_key(self, key: str) -> "ResponseAssertions":
|
|
136
|
+
"""Assert the JSON body contains *key* at the top level."""
|
|
137
|
+
body = self._json_body()
|
|
138
|
+
passed = isinstance(body, dict) and key in body
|
|
139
|
+
_emit("has_key", passed, key=key)
|
|
140
|
+
if not passed:
|
|
141
|
+
raise AssertionError(
|
|
142
|
+
f"Expected JSON key '{key}' to be present.\n"
|
|
143
|
+
f" Body keys: {list(body.keys()) if isinstance(body, dict) else body}"
|
|
144
|
+
)
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def key_equals(self, key: str, expected: Any) -> "ResponseAssertions":
|
|
148
|
+
"""Assert top-level JSON key *key* equals *expected*."""
|
|
149
|
+
body = self._json_body()
|
|
150
|
+
if not isinstance(body, dict):
|
|
151
|
+
raise AssertionError(f"Response body is not a JSON object: {body!r}")
|
|
152
|
+
actual = body.get(key, _UNSET)
|
|
153
|
+
if actual is _UNSET:
|
|
154
|
+
_emit("key_equals", False, key=key, expected=expected, actual="<missing>")
|
|
155
|
+
raise AssertionError(f"Key '{key}' not found in response body.")
|
|
156
|
+
passed = actual == expected
|
|
157
|
+
_emit("key_equals", passed, key=key, expected=expected, actual=actual)
|
|
158
|
+
if not passed:
|
|
159
|
+
raise AssertionError(
|
|
160
|
+
f"Expected body['{key}'] = {expected!r}, got {actual!r}."
|
|
161
|
+
)
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
def body_contains(self, text: str) -> "ResponseAssertions":
|
|
165
|
+
"""Assert the raw response text contains *text*."""
|
|
166
|
+
if text not in self._response.text:
|
|
167
|
+
raise AssertionError(
|
|
168
|
+
f"Expected response body to contain {text!r}.\n"
|
|
169
|
+
f" Body (first 300 chars): {self._response.text[:300]}"
|
|
170
|
+
)
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
def is_json_list(self) -> "ResponseAssertions":
|
|
174
|
+
"""Assert the JSON body is an array."""
|
|
175
|
+
body = self._json_body()
|
|
176
|
+
if not isinstance(body, list):
|
|
177
|
+
raise AssertionError(f"Expected JSON array, got {type(body).__name__}.")
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def list_length(self, expected: int) -> "ResponseAssertions":
|
|
181
|
+
"""Assert the JSON array body has exactly *expected* items."""
|
|
182
|
+
body = self._json_body()
|
|
183
|
+
if not isinstance(body, list):
|
|
184
|
+
raise AssertionError(f"Expected JSON array, got {type(body).__name__}.")
|
|
185
|
+
if len(body) != expected:
|
|
186
|
+
raise AssertionError(
|
|
187
|
+
f"Expected JSON list length {expected}, got {len(body)}."
|
|
188
|
+
)
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def list_length_gte(self, minimum: int) -> "ResponseAssertions":
|
|
192
|
+
"""Assert the JSON array length is at least *minimum*."""
|
|
193
|
+
body = self._json_body()
|
|
194
|
+
if not isinstance(body, list) or len(body) < minimum:
|
|
195
|
+
raise AssertionError(
|
|
196
|
+
f"Expected JSON list length >= {minimum}, got {len(body) if isinstance(body, list) else 'non-list'}."
|
|
197
|
+
)
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
# JSONPath navigation (simple dot-notation; no external lib required)
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def json_path(self, path: str) -> "ResponseAssertions":
|
|
205
|
+
"""
|
|
206
|
+
Navigate to a value in the JSON body using a simple path expression.
|
|
207
|
+
|
|
208
|
+
Supported syntax:
|
|
209
|
+
``$.key`` — top-level key
|
|
210
|
+
``$.key.nested`` — nested key
|
|
211
|
+
``$.items[0].id`` — array index
|
|
212
|
+
``$[0].id`` — root is array
|
|
213
|
+
|
|
214
|
+
The extracted value is stored for the following ``.equals()`` /
|
|
215
|
+
``.matches()`` call.
|
|
216
|
+
"""
|
|
217
|
+
body = self._json_body()
|
|
218
|
+
self._json_path_value = _resolve_path(body, path)
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def equals(self, expected: Any) -> "ResponseAssertions":
|
|
222
|
+
"""Assert the value from the last ``.json_path()`` call equals *expected*."""
|
|
223
|
+
if self._json_path_value is _UNSET:
|
|
224
|
+
raise RuntimeError("Call .json_path() before .equals()")
|
|
225
|
+
actual = self._json_path_value
|
|
226
|
+
self._json_path_value = _UNSET
|
|
227
|
+
if actual != expected:
|
|
228
|
+
raise AssertionError(f"Expected {expected!r}, got {actual!r}.")
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def matches(self, pattern: str) -> "ResponseAssertions":
|
|
232
|
+
"""Assert the value from the last ``.json_path()`` call matches regex *pattern*."""
|
|
233
|
+
if self._json_path_value is _UNSET:
|
|
234
|
+
raise RuntimeError("Call .json_path() before .matches()")
|
|
235
|
+
actual = str(self._json_path_value)
|
|
236
|
+
self._json_path_value = _UNSET
|
|
237
|
+
if not re.search(pattern, actual):
|
|
238
|
+
raise AssertionError(
|
|
239
|
+
f"Expected value to match /{pattern}/, got {actual!r}."
|
|
240
|
+
)
|
|
241
|
+
return self
|
|
242
|
+
|
|
243
|
+
def is_not_none(self) -> "ResponseAssertions":
|
|
244
|
+
"""Assert the value from the last ``.json_path()`` call is not None."""
|
|
245
|
+
if self._json_path_value is _UNSET:
|
|
246
|
+
raise RuntimeError("Call .json_path() before .is_not_none()")
|
|
247
|
+
actual = self._json_path_value
|
|
248
|
+
self._json_path_value = _UNSET
|
|
249
|
+
if actual is None:
|
|
250
|
+
raise AssertionError("Expected value to be non-null.")
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
# Performance
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def response_time_under(self, max_ms: float) -> "ResponseAssertions":
|
|
258
|
+
"""Assert the response time is under *max_ms* milliseconds."""
|
|
259
|
+
actual = self._response.elapsed_ms
|
|
260
|
+
passed = actual < max_ms
|
|
261
|
+
_emit("response_time_under", passed, max_ms=max_ms, actual_ms=round(actual, 1))
|
|
262
|
+
if not passed:
|
|
263
|
+
raise AssertionError(
|
|
264
|
+
f"Expected response time < {max_ms}ms, got {actual:.1f}ms.\n"
|
|
265
|
+
f" URL: {self._response.url}"
|
|
266
|
+
)
|
|
267
|
+
return self
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
# Schema validation (uses jsonschema if available)
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def matches_schema(self, schema: dict[str, Any]) -> "ResponseAssertions":
|
|
274
|
+
"""Assert the JSON body conforms to the given JSON Schema."""
|
|
275
|
+
try:
|
|
276
|
+
import jsonschema
|
|
277
|
+
|
|
278
|
+
jsonschema.validate(instance=self._json_body(), schema=schema)
|
|
279
|
+
except ImportError:
|
|
280
|
+
raise ImportError(
|
|
281
|
+
"jsonschema is required for schema validation: pip install jsonschema"
|
|
282
|
+
)
|
|
283
|
+
except jsonschema.ValidationError as exc:
|
|
284
|
+
raise AssertionError(f"Schema validation failed: {exc.message}") from exc
|
|
285
|
+
return self
|
|
286
|
+
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
# Internal helpers
|
|
289
|
+
# ------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def _json_body(self) -> Any:
|
|
292
|
+
try:
|
|
293
|
+
return self._response.json()
|
|
294
|
+
except (ValueError, TypeError) as exc:
|
|
295
|
+
raise AssertionError(
|
|
296
|
+
f"Response body is not valid JSON.\n"
|
|
297
|
+
f" Status: {self._response.status_code}\n"
|
|
298
|
+
f" Body: {self._response.text[:200]}"
|
|
299
|
+
) from exc
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# Sentinel & helpers
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class _Unset:
|
|
308
|
+
"""Sentinel for unset json_path value."""
|
|
309
|
+
def __repr__(self) -> str:
|
|
310
|
+
return "<UNSET>"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
_UNSET = _Unset()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _resolve_path(data: Any, path: str) -> Any:
|
|
317
|
+
"""Resolve a simple ``$.key.nested[0]`` path against *data*."""
|
|
318
|
+
# Strip leading $
|
|
319
|
+
path = path.lstrip("$")
|
|
320
|
+
# Tokenise on "." and "[N]"
|
|
321
|
+
tokens: list[str | int] = []
|
|
322
|
+
for segment in re.split(r"\.|\[(\d+)\]", path):
|
|
323
|
+
if segment is None or segment == "":
|
|
324
|
+
continue
|
|
325
|
+
try:
|
|
326
|
+
tokens.append(int(segment))
|
|
327
|
+
except ValueError:
|
|
328
|
+
tokens.append(segment)
|
|
329
|
+
|
|
330
|
+
current = data
|
|
331
|
+
for token in tokens:
|
|
332
|
+
if current is None:
|
|
333
|
+
raise AssertionError(f"Path traversal failed at token {token!r}: value is null.")
|
|
334
|
+
try:
|
|
335
|
+
current = current[token]
|
|
336
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
337
|
+
raise AssertionError(
|
|
338
|
+
f"Path token {token!r} not found in {type(current).__name__}: {current!r}"
|
|
339
|
+
) from exc
|
|
340
|
+
return current
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
# Public factory
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def assert_that(response: APIResponse) -> ResponseAssertions:
|
|
349
|
+
"""Entry-point for the fluent assertion chain."""
|
|
350
|
+
return ResponseAssertions(response)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication strategy classes compatible with requests.auth.AuthBase.
|
|
3
|
+
|
|
4
|
+
Usage
|
|
5
|
+
-----
|
|
6
|
+
client = APIClient(base_url, auth=BearerAuth("my-token"))
|
|
7
|
+
client = APIClient(base_url, auth=BasicAuth("user", "pass"))
|
|
8
|
+
client = APIClient(base_url, auth=APIKeyAuth("x-api-key", "secret"))
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
from requests.auth import AuthBase
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("pytest_api_core.auth")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BearerAuth(AuthBase):
|
|
23
|
+
"""Injects ``Authorization: Bearer <token>`` into every request."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, token: str) -> None:
|
|
26
|
+
self._token = token
|
|
27
|
+
|
|
28
|
+
def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
|
|
29
|
+
r.headers["Authorization"] = f"Bearer {self._token}"
|
|
30
|
+
return r
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return f"<BearerAuth token=***{self._token[-4:]}>"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BasicAuth(AuthBase):
|
|
37
|
+
"""
|
|
38
|
+
HTTP Basic Authentication.
|
|
39
|
+
Delegates to requests' built-in HTTPBasicAuth but subclasses AuthBase
|
|
40
|
+
so it integrates cleanly with the fixture system.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, username: str, password: str) -> None:
|
|
44
|
+
self._inner = requests.auth.HTTPBasicAuth(username, password)
|
|
45
|
+
|
|
46
|
+
def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
|
|
47
|
+
return self._inner(r)
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
return f"<BasicAuth username={self._inner.username!r}>"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class APIKeyAuth(AuthBase):
|
|
54
|
+
"""
|
|
55
|
+
Injects an API key either as a request header or a query parameter.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
name:
|
|
60
|
+
Header name (e.g. ``x-api-key``) or query-parameter name.
|
|
61
|
+
value:
|
|
62
|
+
The API key value.
|
|
63
|
+
location:
|
|
64
|
+
``"header"`` (default) or ``"query"``.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, name: str, value: str, location: str = "header") -> None:
|
|
68
|
+
if location not in ("header", "query"):
|
|
69
|
+
raise ValueError("location must be 'header' or 'query'")
|
|
70
|
+
self._name = name
|
|
71
|
+
self._value = value
|
|
72
|
+
self._location = location
|
|
73
|
+
|
|
74
|
+
def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
|
|
75
|
+
if self._location == "header":
|
|
76
|
+
r.headers[self._name] = self._value
|
|
77
|
+
else:
|
|
78
|
+
# Append to existing query string
|
|
79
|
+
from urllib.parse import urlencode, urlparse, urlunparse, parse_qsl
|
|
80
|
+
|
|
81
|
+
parsed = urlparse(r.url or "")
|
|
82
|
+
params = parse_qsl(parsed.query)
|
|
83
|
+
params.append((self._name, self._value))
|
|
84
|
+
r.url = urlunparse(parsed._replace(query=urlencode(params)))
|
|
85
|
+
return r
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"<APIKeyAuth name={self._name!r} location={self._location!r}>"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class OAuth2ClientCredentials(AuthBase):
|
|
92
|
+
"""
|
|
93
|
+
Fetches and caches an OAuth2 access token using the *client credentials* flow.
|
|
94
|
+
Automatically refreshes when the token is within ``refresh_buffer`` seconds of expiry.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
token_url:
|
|
99
|
+
Full URL of the token endpoint.
|
|
100
|
+
client_id:
|
|
101
|
+
OAuth2 client ID.
|
|
102
|
+
client_secret:
|
|
103
|
+
OAuth2 client secret.
|
|
104
|
+
scope:
|
|
105
|
+
Space-separated list of scopes (optional).
|
|
106
|
+
refresh_buffer:
|
|
107
|
+
Seconds before expiry to proactively refresh (default 30).
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
token_url: str,
|
|
113
|
+
client_id: str,
|
|
114
|
+
client_secret: str,
|
|
115
|
+
scope: str = "",
|
|
116
|
+
refresh_buffer: int = 30,
|
|
117
|
+
) -> None:
|
|
118
|
+
self._token_url = token_url
|
|
119
|
+
self._client_id = client_id
|
|
120
|
+
self._client_secret = client_secret
|
|
121
|
+
self._scope = scope
|
|
122
|
+
self._refresh_buffer = refresh_buffer
|
|
123
|
+
|
|
124
|
+
self._access_token: str | None = None
|
|
125
|
+
self._expires_at: float = 0.0
|
|
126
|
+
|
|
127
|
+
def _fetch_token(self) -> None:
|
|
128
|
+
data: dict[str, str] = {
|
|
129
|
+
"grant_type": "client_credentials",
|
|
130
|
+
"client_id": self._client_id,
|
|
131
|
+
"client_secret": self._client_secret,
|
|
132
|
+
}
|
|
133
|
+
if self._scope:
|
|
134
|
+
data["scope"] = self._scope
|
|
135
|
+
|
|
136
|
+
log.debug("Fetching OAuth2 token from %s", self._token_url)
|
|
137
|
+
response = requests.post(self._token_url, data=data, timeout=15)
|
|
138
|
+
response.raise_for_status()
|
|
139
|
+
|
|
140
|
+
payload: dict[str, Any] = response.json()
|
|
141
|
+
self._access_token = payload["access_token"]
|
|
142
|
+
expires_in: int = payload.get("expires_in", 3600)
|
|
143
|
+
self._expires_at = time.monotonic() + expires_in
|
|
144
|
+
log.debug("OAuth2 token acquired, expires in %ds", expires_in)
|
|
145
|
+
|
|
146
|
+
def _is_expired(self) -> bool:
|
|
147
|
+
return time.monotonic() >= (self._expires_at - self._refresh_buffer)
|
|
148
|
+
|
|
149
|
+
def __call__(self, r: requests.PreparedRequest) -> requests.PreparedRequest:
|
|
150
|
+
if self._access_token is None or self._is_expired():
|
|
151
|
+
self._fetch_token()
|
|
152
|
+
r.headers["Authorization"] = f"Bearer {self._access_token}"
|
|
153
|
+
return r
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str:
|
|
156
|
+
return f"<OAuth2ClientCredentials client_id={self._client_id!r}>"
|