crashbytes-apiclient 1.0.0__tar.gz
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.
- crashbytes_apiclient-1.0.0/.github/CODEOWNERS +1 -0
- crashbytes_apiclient-1.0.0/.github/workflows/ci.yml +23 -0
- crashbytes_apiclient-1.0.0/.github/workflows/publish.yml +19 -0
- crashbytes_apiclient-1.0.0/.gitignore +11 -0
- crashbytes_apiclient-1.0.0/LICENSE +21 -0
- crashbytes_apiclient-1.0.0/PKG-INFO +77 -0
- crashbytes_apiclient-1.0.0/README.md +46 -0
- crashbytes_apiclient-1.0.0/pyproject.toml +55 -0
- crashbytes_apiclient-1.0.0/src/crashbytes_apiclient/__init__.py +21 -0
- crashbytes_apiclient-1.0.0/src/crashbytes_apiclient/_core.py +280 -0
- crashbytes_apiclient-1.0.0/tests/test_apiclient.py +315 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* @CrashBytes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e ".[dev]"
|
|
21
|
+
- run: ruff check src/ tests/
|
|
22
|
+
- run: mypy --strict src/
|
|
23
|
+
- run: pytest --cov=crashbytes_apiclient --cov-branch --cov-fail-under=90
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
- run: pip install build
|
|
18
|
+
- run: python -m build
|
|
19
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CrashBytes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crashbytes-apiclient
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: httpx API client toolkit — pagination, retry, rate limiting, auth refresh, middleware.
|
|
5
|
+
Project-URL: Homepage, https://github.com/CrashBytes/crashbytes-apiclient
|
|
6
|
+
Project-URL: Repository, https://github.com/CrashBytes/crashbytes-apiclient
|
|
7
|
+
Project-URL: Issues, https://github.com/CrashBytes/crashbytes-apiclient/issues
|
|
8
|
+
Author-email: CrashBytes <crashbytes@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api-client,httpx,middleware,pagination,retry
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-httpx; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# crashbytes-apiclient
|
|
33
|
+
|
|
34
|
+
httpx API client toolkit — pagination, retry, rate limiting, auth refresh, middleware.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install crashbytes-apiclient
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from crashbytes_apiclient import (
|
|
46
|
+
ApiClient, BearerAuth, RetryMiddleware, CursorPaginator,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
client = ApiClient(
|
|
50
|
+
base_url="https://api.example.com",
|
|
51
|
+
auth=BearerAuth("your-token"),
|
|
52
|
+
middlewares=[RetryMiddleware(max_retries=3)],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Simple requests
|
|
56
|
+
response = client.get("/users")
|
|
57
|
+
response = client.post("/users", json={"name": "Alice"})
|
|
58
|
+
|
|
59
|
+
# Paginated iteration
|
|
60
|
+
for batch in client.paginate("/items", CursorPaginator()):
|
|
61
|
+
for item in batch:
|
|
62
|
+
process(item)
|
|
63
|
+
|
|
64
|
+
client.close()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Features
|
|
68
|
+
|
|
69
|
+
- **Auth:** `BearerAuth`, `RefreshableAuth` (auto-refresh on 401)
|
|
70
|
+
- **Middleware:** `RetryMiddleware`, `RateLimitMiddleware`, custom middleware protocol
|
|
71
|
+
- **Pagination:** `CursorPaginator`, `PageNumberPaginator`
|
|
72
|
+
- **HTTP Methods:** `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`
|
|
73
|
+
- **Context Manager:** `with ApiClient(...) as client:`
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# crashbytes-apiclient
|
|
2
|
+
|
|
3
|
+
httpx API client toolkit — pagination, retry, rate limiting, auth refresh, middleware.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install crashbytes-apiclient
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from crashbytes_apiclient import (
|
|
15
|
+
ApiClient, BearerAuth, RetryMiddleware, CursorPaginator,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
client = ApiClient(
|
|
19
|
+
base_url="https://api.example.com",
|
|
20
|
+
auth=BearerAuth("your-token"),
|
|
21
|
+
middlewares=[RetryMiddleware(max_retries=3)],
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Simple requests
|
|
25
|
+
response = client.get("/users")
|
|
26
|
+
response = client.post("/users", json={"name": "Alice"})
|
|
27
|
+
|
|
28
|
+
# Paginated iteration
|
|
29
|
+
for batch in client.paginate("/items", CursorPaginator()):
|
|
30
|
+
for item in batch:
|
|
31
|
+
process(item)
|
|
32
|
+
|
|
33
|
+
client.close()
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **Auth:** `BearerAuth`, `RefreshableAuth` (auto-refresh on 401)
|
|
39
|
+
- **Middleware:** `RetryMiddleware`, `RateLimitMiddleware`, custom middleware protocol
|
|
40
|
+
- **Pagination:** `CursorPaginator`, `PageNumberPaginator`
|
|
41
|
+
- **HTTP Methods:** `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`
|
|
42
|
+
- **Context Manager:** `with ApiClient(...) as client:`
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crashbytes-apiclient"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "httpx API client toolkit — pagination, retry, rate limiting, auth refresh, middleware."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["httpx>=0.27"]
|
|
13
|
+
authors = [{ name = "CrashBytes", email = "crashbytes@users.noreply.github.com" }]
|
|
14
|
+
keywords = ["httpx", "api-client", "pagination", "retry", "middleware"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest", "pytest-cov", "pytest-asyncio", "pytest-httpx", "mypy", "ruff"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/CrashBytes/crashbytes-apiclient"
|
|
32
|
+
Repository = "https://github.com/CrashBytes/crashbytes-apiclient"
|
|
33
|
+
Issues = "https://github.com/CrashBytes/crashbytes-apiclient/issues"
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
target-version = "py310"
|
|
37
|
+
line-length = 99
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = ["E", "F", "I", "N", "UP", "B", "SIM", "TCH"]
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
strict = true
|
|
44
|
+
python_version = "3.10"
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
|
|
50
|
+
[tool.coverage.run]
|
|
51
|
+
branch = true
|
|
52
|
+
source = ["crashbytes_apiclient"]
|
|
53
|
+
|
|
54
|
+
[tool.coverage.report]
|
|
55
|
+
fail_under = 90
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""crashbytes-apiclient — httpx API client toolkit."""
|
|
2
|
+
|
|
3
|
+
from crashbytes_apiclient._core import (
|
|
4
|
+
ApiClient,
|
|
5
|
+
BearerAuth,
|
|
6
|
+
CursorPaginator,
|
|
7
|
+
PageNumberPaginator,
|
|
8
|
+
RateLimitMiddleware,
|
|
9
|
+
RefreshableAuth,
|
|
10
|
+
RetryMiddleware,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ApiClient",
|
|
15
|
+
"BearerAuth",
|
|
16
|
+
"CursorPaginator",
|
|
17
|
+
"PageNumberPaginator",
|
|
18
|
+
"RateLimitMiddleware",
|
|
19
|
+
"RefreshableAuth",
|
|
20
|
+
"RetryMiddleware",
|
|
21
|
+
]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""httpx API client toolkit — pagination, retry, rate limiting, auth, middleware."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Generator, Iterator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Auth ────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BearerAuth(httpx.Auth):
|
|
18
|
+
"""Static bearer token authentication."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, token: str) -> None:
|
|
21
|
+
self._token = token
|
|
22
|
+
|
|
23
|
+
def auth_flow(
|
|
24
|
+
self, request: httpx.Request
|
|
25
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
26
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
27
|
+
yield request
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RefreshableAuth(httpx.Auth):
|
|
31
|
+
"""Bearer auth that refreshes the token when it expires."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
token: str,
|
|
36
|
+
refresh_fn: Any,
|
|
37
|
+
refresh_status: int = 401,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._token = token
|
|
40
|
+
self._refresh_fn = refresh_fn
|
|
41
|
+
self._refresh_status = refresh_status
|
|
42
|
+
|
|
43
|
+
def auth_flow(
|
|
44
|
+
self, request: httpx.Request
|
|
45
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
46
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
47
|
+
response = yield request
|
|
48
|
+
if response.status_code == self._refresh_status:
|
|
49
|
+
self._token = self._refresh_fn()
|
|
50
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
51
|
+
yield request
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Middleware Protocol ─────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Middleware(Protocol):
|
|
58
|
+
"""Protocol for request/response middleware."""
|
|
59
|
+
|
|
60
|
+
def process_request(self, request: httpx.Request) -> httpx.Request:
|
|
61
|
+
"""Process the request before sending."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def process_response(self, response: httpx.Response) -> httpx.Response:
|
|
65
|
+
"""Process the response after receiving."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RetryMiddleware:
|
|
70
|
+
"""Retry failed requests with exponential backoff."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
max_retries: int = 3,
|
|
75
|
+
retry_statuses: tuple[int, ...] = (429, 500, 502, 503, 504),
|
|
76
|
+
delay: float = 0.5,
|
|
77
|
+
backoff: float = 2.0,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.max_retries = max_retries
|
|
80
|
+
self.retry_statuses = retry_statuses
|
|
81
|
+
self.delay = delay
|
|
82
|
+
self.backoff = backoff
|
|
83
|
+
|
|
84
|
+
def process_request(self, request: httpx.Request) -> httpx.Request:
|
|
85
|
+
return request
|
|
86
|
+
|
|
87
|
+
def process_response(self, response: httpx.Response) -> httpx.Response:
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
def should_retry(self, response: httpx.Response) -> bool:
|
|
91
|
+
"""Check if the response should trigger a retry."""
|
|
92
|
+
return response.status_code in self.retry_statuses
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RateLimitMiddleware:
|
|
96
|
+
"""Simple rate limiting between requests."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, min_interval: float = 0.1) -> None:
|
|
99
|
+
self._min_interval = min_interval
|
|
100
|
+
self._last_request_time = 0.0
|
|
101
|
+
|
|
102
|
+
def process_request(self, request: httpx.Request) -> httpx.Request:
|
|
103
|
+
elapsed = time.monotonic() - self._last_request_time
|
|
104
|
+
if elapsed < self._min_interval:
|
|
105
|
+
time.sleep(self._min_interval - elapsed)
|
|
106
|
+
self._last_request_time = time.monotonic()
|
|
107
|
+
return request
|
|
108
|
+
|
|
109
|
+
def process_response(self, response: httpx.Response) -> httpx.Response:
|
|
110
|
+
return response
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Paginator Protocols ─────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CursorPaginator:
|
|
117
|
+
"""Extract next-page cursor from response JSON."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
cursor_param: str = "cursor",
|
|
122
|
+
cursor_path: str = "next_cursor",
|
|
123
|
+
results_path: str = "results",
|
|
124
|
+
) -> None:
|
|
125
|
+
self._cursor_param = cursor_param
|
|
126
|
+
self._cursor_path = cursor_path
|
|
127
|
+
self._results_path = results_path
|
|
128
|
+
|
|
129
|
+
def get_results(self, data: dict[str, Any]) -> list[Any]:
|
|
130
|
+
"""Extract results from the response data."""
|
|
131
|
+
return data.get(self._results_path, []) # type: ignore[no-any-return]
|
|
132
|
+
|
|
133
|
+
def get_next_params(self, data: dict[str, Any]) -> dict[str, str] | None:
|
|
134
|
+
"""Get params for the next page, or None if done."""
|
|
135
|
+
cursor = data.get(self._cursor_path)
|
|
136
|
+
if cursor:
|
|
137
|
+
return {self._cursor_param: str(cursor)}
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class PageNumberPaginator:
|
|
142
|
+
"""Page-number-based pagination."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
page_param: str = "page",
|
|
147
|
+
results_path: str = "results",
|
|
148
|
+
total_path: str = "total_pages",
|
|
149
|
+
) -> None:
|
|
150
|
+
self._page_param = page_param
|
|
151
|
+
self._results_path = results_path
|
|
152
|
+
self._total_path = total_path
|
|
153
|
+
self._current_page = 1
|
|
154
|
+
|
|
155
|
+
def get_results(self, data: dict[str, Any]) -> list[Any]:
|
|
156
|
+
"""Extract results from the response data."""
|
|
157
|
+
return data.get(self._results_path, []) # type: ignore[no-any-return]
|
|
158
|
+
|
|
159
|
+
def get_next_params(self, data: dict[str, Any]) -> dict[str, str] | None:
|
|
160
|
+
"""Get params for the next page, or None if done."""
|
|
161
|
+
total = data.get(self._total_path, 0)
|
|
162
|
+
if self._current_page < int(total):
|
|
163
|
+
self._current_page += 1
|
|
164
|
+
return {self._page_param: str(self._current_page)}
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── API Client ──────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ApiClient:
|
|
172
|
+
"""High-level httpx API client with middleware and pagination."""
|
|
173
|
+
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
base_url: str,
|
|
177
|
+
auth: httpx.Auth | None = None,
|
|
178
|
+
middlewares: list[Any] | None = None,
|
|
179
|
+
headers: dict[str, str] | None = None,
|
|
180
|
+
timeout: float = 30.0,
|
|
181
|
+
) -> None:
|
|
182
|
+
self._base_url = base_url.rstrip("/")
|
|
183
|
+
self._auth = auth
|
|
184
|
+
self._middlewares = middlewares or []
|
|
185
|
+
self._client = httpx.Client(
|
|
186
|
+
base_url=self._base_url,
|
|
187
|
+
auth=auth,
|
|
188
|
+
headers=headers or {},
|
|
189
|
+
timeout=timeout,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _apply_request_middleware(self, request: httpx.Request) -> httpx.Request:
|
|
193
|
+
for mw in self._middlewares:
|
|
194
|
+
request = mw.process_request(request)
|
|
195
|
+
return request
|
|
196
|
+
|
|
197
|
+
def _apply_response_middleware(self, response: httpx.Response) -> httpx.Response:
|
|
198
|
+
for mw in self._middlewares:
|
|
199
|
+
response = mw.process_response(response)
|
|
200
|
+
return response
|
|
201
|
+
|
|
202
|
+
def _send_with_retry(self, request: httpx.Request) -> httpx.Response:
|
|
203
|
+
retry_mw = None
|
|
204
|
+
for mw in self._middlewares:
|
|
205
|
+
if isinstance(mw, RetryMiddleware):
|
|
206
|
+
retry_mw = mw
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
if retry_mw is None:
|
|
210
|
+
return self._client.send(request)
|
|
211
|
+
|
|
212
|
+
delay = retry_mw.delay
|
|
213
|
+
last_response: httpx.Response | None = None
|
|
214
|
+
for attempt in range(retry_mw.max_retries + 1):
|
|
215
|
+
response = self._client.send(request)
|
|
216
|
+
last_response = response
|
|
217
|
+
if not retry_mw.should_retry(response):
|
|
218
|
+
return response
|
|
219
|
+
if attempt < retry_mw.max_retries:
|
|
220
|
+
time.sleep(delay)
|
|
221
|
+
delay *= retry_mw.backoff
|
|
222
|
+
assert last_response is not None
|
|
223
|
+
return last_response
|
|
224
|
+
|
|
225
|
+
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
226
|
+
"""Send an HTTP request."""
|
|
227
|
+
request = self._client.build_request(method, path, **kwargs)
|
|
228
|
+
request = self._apply_request_middleware(request)
|
|
229
|
+
response = self._send_with_retry(request)
|
|
230
|
+
return self._apply_response_middleware(response)
|
|
231
|
+
|
|
232
|
+
def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
233
|
+
"""Send a GET request."""
|
|
234
|
+
return self.request("GET", path, **kwargs)
|
|
235
|
+
|
|
236
|
+
def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
237
|
+
"""Send a POST request."""
|
|
238
|
+
return self.request("POST", path, **kwargs)
|
|
239
|
+
|
|
240
|
+
def put(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
241
|
+
"""Send a PUT request."""
|
|
242
|
+
return self.request("PUT", path, **kwargs)
|
|
243
|
+
|
|
244
|
+
def patch(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
245
|
+
"""Send a PATCH request."""
|
|
246
|
+
return self.request("PATCH", path, **kwargs)
|
|
247
|
+
|
|
248
|
+
def delete(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
249
|
+
"""Send a DELETE request."""
|
|
250
|
+
return self.request("DELETE", path, **kwargs)
|
|
251
|
+
|
|
252
|
+
def paginate(
|
|
253
|
+
self,
|
|
254
|
+
path: str,
|
|
255
|
+
paginator: CursorPaginator | PageNumberPaginator,
|
|
256
|
+
**kwargs: Any,
|
|
257
|
+
) -> Iterator[list[Any]]:
|
|
258
|
+
"""Iterate through paginated results."""
|
|
259
|
+
params: dict[str, str] = dict(kwargs.pop("params", {}))
|
|
260
|
+
while True:
|
|
261
|
+
response = self.get(path, params=params, **kwargs)
|
|
262
|
+
response.raise_for_status()
|
|
263
|
+
data = response.json()
|
|
264
|
+
results = paginator.get_results(data)
|
|
265
|
+
if results:
|
|
266
|
+
yield results
|
|
267
|
+
next_params = paginator.get_next_params(data)
|
|
268
|
+
if next_params is None:
|
|
269
|
+
break
|
|
270
|
+
params.update(next_params)
|
|
271
|
+
|
|
272
|
+
def close(self) -> None:
|
|
273
|
+
"""Close the underlying httpx client."""
|
|
274
|
+
self._client.close()
|
|
275
|
+
|
|
276
|
+
def __enter__(self) -> ApiClient:
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
def __exit__(self, *args: Any) -> None:
|
|
280
|
+
self.close()
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Tests for crashbytes-apiclient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from crashbytes_apiclient import (
|
|
11
|
+
ApiClient,
|
|
12
|
+
BearerAuth,
|
|
13
|
+
CursorPaginator,
|
|
14
|
+
PageNumberPaginator,
|
|
15
|
+
RateLimitMiddleware,
|
|
16
|
+
RefreshableAuth,
|
|
17
|
+
RetryMiddleware,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# ── Auth Tests ──────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestBearerAuth:
|
|
24
|
+
def test_adds_header(self) -> None:
|
|
25
|
+
auth = BearerAuth("mytoken")
|
|
26
|
+
request = httpx.Request("GET", "https://example.com")
|
|
27
|
+
flow = auth.auth_flow(request)
|
|
28
|
+
modified = next(flow)
|
|
29
|
+
assert modified.headers["Authorization"] == "Bearer mytoken"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestRefreshableAuth:
|
|
33
|
+
def test_no_refresh_on_success(self) -> None:
|
|
34
|
+
auth = RefreshableAuth("token1", lambda: "token2")
|
|
35
|
+
request = httpx.Request("GET", "https://example.com")
|
|
36
|
+
flow = auth.auth_flow(request)
|
|
37
|
+
modified = next(flow)
|
|
38
|
+
assert modified.headers["Authorization"] == "Bearer token1"
|
|
39
|
+
|
|
40
|
+
def test_refresh_on_401(self) -> None:
|
|
41
|
+
refresh_fn = MagicMock(return_value="token2")
|
|
42
|
+
auth = RefreshableAuth("token1", refresh_fn)
|
|
43
|
+
request = httpx.Request("GET", "https://example.com")
|
|
44
|
+
flow = auth.auth_flow(request)
|
|
45
|
+
modified = next(flow)
|
|
46
|
+
assert modified.headers["Authorization"] == "Bearer token1"
|
|
47
|
+
|
|
48
|
+
# Simulate 401 response
|
|
49
|
+
response = httpx.Response(401, request=request)
|
|
50
|
+
modified = flow.send(response)
|
|
51
|
+
assert modified.headers["Authorization"] == "Bearer token2"
|
|
52
|
+
refresh_fn.assert_called_once()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Middleware Tests ────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestRetryMiddleware:
|
|
59
|
+
def test_should_retry_on_503(self) -> None:
|
|
60
|
+
mw = RetryMiddleware()
|
|
61
|
+
response = httpx.Response(503)
|
|
62
|
+
assert mw.should_retry(response) is True
|
|
63
|
+
|
|
64
|
+
def test_should_not_retry_on_200(self) -> None:
|
|
65
|
+
mw = RetryMiddleware()
|
|
66
|
+
response = httpx.Response(200)
|
|
67
|
+
assert mw.should_retry(response) is False
|
|
68
|
+
|
|
69
|
+
def test_process_request_passthrough(self) -> None:
|
|
70
|
+
mw = RetryMiddleware()
|
|
71
|
+
request = httpx.Request("GET", "https://example.com")
|
|
72
|
+
assert mw.process_request(request) is request
|
|
73
|
+
|
|
74
|
+
def test_process_response_passthrough(self) -> None:
|
|
75
|
+
mw = RetryMiddleware()
|
|
76
|
+
response = httpx.Response(200)
|
|
77
|
+
assert mw.process_response(response) is response
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestRateLimitMiddleware:
|
|
81
|
+
def test_process_request(self) -> None:
|
|
82
|
+
mw = RateLimitMiddleware(min_interval=0.0)
|
|
83
|
+
request = httpx.Request("GET", "https://example.com")
|
|
84
|
+
result = mw.process_request(request)
|
|
85
|
+
assert result is request
|
|
86
|
+
|
|
87
|
+
def test_process_response_passthrough(self) -> None:
|
|
88
|
+
mw = RateLimitMiddleware()
|
|
89
|
+
response = httpx.Response(200)
|
|
90
|
+
assert mw.process_response(response) is response
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Paginator Tests ─────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestCursorPaginator:
|
|
97
|
+
def test_get_results(self) -> None:
|
|
98
|
+
pag = CursorPaginator()
|
|
99
|
+
data: dict[str, Any] = {"results": [1, 2, 3], "next_cursor": "abc"}
|
|
100
|
+
assert pag.get_results(data) == [1, 2, 3]
|
|
101
|
+
|
|
102
|
+
def test_get_next_params(self) -> None:
|
|
103
|
+
pag = CursorPaginator()
|
|
104
|
+
data: dict[str, Any] = {"results": [], "next_cursor": "abc"}
|
|
105
|
+
assert pag.get_next_params(data) == {"cursor": "abc"}
|
|
106
|
+
|
|
107
|
+
def test_get_next_params_none(self) -> None:
|
|
108
|
+
pag = CursorPaginator()
|
|
109
|
+
data: dict[str, Any] = {"results": [], "next_cursor": None}
|
|
110
|
+
assert pag.get_next_params(data) is None
|
|
111
|
+
|
|
112
|
+
def test_get_next_params_missing(self) -> None:
|
|
113
|
+
pag = CursorPaginator()
|
|
114
|
+
data: dict[str, Any] = {"results": []}
|
|
115
|
+
assert pag.get_next_params(data) is None
|
|
116
|
+
|
|
117
|
+
def test_custom_keys(self) -> None:
|
|
118
|
+
pag = CursorPaginator(
|
|
119
|
+
cursor_param="after", cursor_path="paging.next", results_path="data"
|
|
120
|
+
)
|
|
121
|
+
data: dict[str, Any] = {"data": [1], "paging.next": "xyz"}
|
|
122
|
+
assert pag.get_results(data) == [1]
|
|
123
|
+
assert pag.get_next_params(data) == {"after": "xyz"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestPageNumberPaginator:
|
|
127
|
+
def test_get_results(self) -> None:
|
|
128
|
+
pag = PageNumberPaginator()
|
|
129
|
+
data: dict[str, Any] = {"results": [1, 2], "total_pages": 3}
|
|
130
|
+
assert pag.get_results(data) == [1, 2]
|
|
131
|
+
|
|
132
|
+
def test_get_next_params(self) -> None:
|
|
133
|
+
pag = PageNumberPaginator()
|
|
134
|
+
data: dict[str, Any] = {"results": [], "total_pages": 3}
|
|
135
|
+
params = pag.get_next_params(data)
|
|
136
|
+
assert params == {"page": "2"}
|
|
137
|
+
|
|
138
|
+
def test_get_next_params_last_page(self) -> None:
|
|
139
|
+
pag = PageNumberPaginator()
|
|
140
|
+
pag._current_page = 3
|
|
141
|
+
data: dict[str, Any] = {"results": [], "total_pages": 3}
|
|
142
|
+
assert pag.get_next_params(data) is None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── ApiClient Tests ─────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestApiClient:
|
|
149
|
+
def test_context_manager(self) -> None:
|
|
150
|
+
transport = httpx.MockTransport(
|
|
151
|
+
lambda req: httpx.Response(200, json={"ok": True})
|
|
152
|
+
)
|
|
153
|
+
with ApiClient("https://api.example.com") as client:
|
|
154
|
+
client._client = httpx.Client(
|
|
155
|
+
base_url="https://api.example.com", transport=transport
|
|
156
|
+
)
|
|
157
|
+
resp = client.get("/test")
|
|
158
|
+
assert resp.status_code == 200
|
|
159
|
+
assert resp.json() == {"ok": True}
|
|
160
|
+
|
|
161
|
+
def test_post(self) -> None:
|
|
162
|
+
transport = httpx.MockTransport(
|
|
163
|
+
lambda req: httpx.Response(201, json={"id": 1})
|
|
164
|
+
)
|
|
165
|
+
with ApiClient("https://api.example.com") as client:
|
|
166
|
+
client._client = httpx.Client(
|
|
167
|
+
base_url="https://api.example.com", transport=transport
|
|
168
|
+
)
|
|
169
|
+
resp = client.post("/items", json={"name": "test"})
|
|
170
|
+
assert resp.status_code == 201
|
|
171
|
+
|
|
172
|
+
def test_put(self) -> None:
|
|
173
|
+
transport = httpx.MockTransport(
|
|
174
|
+
lambda req: httpx.Response(200, json={"updated": True})
|
|
175
|
+
)
|
|
176
|
+
with ApiClient("https://api.example.com") as client:
|
|
177
|
+
client._client = httpx.Client(
|
|
178
|
+
base_url="https://api.example.com", transport=transport
|
|
179
|
+
)
|
|
180
|
+
resp = client.put("/items/1", json={"name": "updated"})
|
|
181
|
+
assert resp.status_code == 200
|
|
182
|
+
|
|
183
|
+
def test_patch(self) -> None:
|
|
184
|
+
transport = httpx.MockTransport(
|
|
185
|
+
lambda req: httpx.Response(200)
|
|
186
|
+
)
|
|
187
|
+
with ApiClient("https://api.example.com") as client:
|
|
188
|
+
client._client = httpx.Client(
|
|
189
|
+
base_url="https://api.example.com", transport=transport
|
|
190
|
+
)
|
|
191
|
+
resp = client.patch("/items/1", json={"name": "patched"})
|
|
192
|
+
assert resp.status_code == 200
|
|
193
|
+
|
|
194
|
+
def test_delete(self) -> None:
|
|
195
|
+
transport = httpx.MockTransport(
|
|
196
|
+
lambda req: httpx.Response(204)
|
|
197
|
+
)
|
|
198
|
+
with ApiClient("https://api.example.com") as client:
|
|
199
|
+
client._client = httpx.Client(
|
|
200
|
+
base_url="https://api.example.com", transport=transport
|
|
201
|
+
)
|
|
202
|
+
resp = client.delete("/items/1")
|
|
203
|
+
assert resp.status_code == 204
|
|
204
|
+
|
|
205
|
+
def test_with_bearer_auth(self) -> None:
|
|
206
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
207
|
+
auth = request.headers.get("Authorization", "")
|
|
208
|
+
return httpx.Response(200, json={"auth": auth})
|
|
209
|
+
|
|
210
|
+
transport = httpx.MockTransport(handler)
|
|
211
|
+
with ApiClient(
|
|
212
|
+
"https://api.example.com", auth=BearerAuth("test-token")
|
|
213
|
+
) as client:
|
|
214
|
+
client._client = httpx.Client(
|
|
215
|
+
base_url="https://api.example.com",
|
|
216
|
+
transport=transport,
|
|
217
|
+
auth=BearerAuth("test-token"),
|
|
218
|
+
)
|
|
219
|
+
resp = client.get("/me")
|
|
220
|
+
assert resp.json()["auth"] == "Bearer test-token"
|
|
221
|
+
|
|
222
|
+
def test_with_middleware(self) -> None:
|
|
223
|
+
call_log: list[str] = []
|
|
224
|
+
|
|
225
|
+
class LogMiddleware:
|
|
226
|
+
def process_request(self, request: httpx.Request) -> httpx.Request:
|
|
227
|
+
call_log.append("request")
|
|
228
|
+
return request
|
|
229
|
+
|
|
230
|
+
def process_response(self, response: httpx.Response) -> httpx.Response:
|
|
231
|
+
call_log.append("response")
|
|
232
|
+
return response
|
|
233
|
+
|
|
234
|
+
transport = httpx.MockTransport(
|
|
235
|
+
lambda req: httpx.Response(200)
|
|
236
|
+
)
|
|
237
|
+
with ApiClient(
|
|
238
|
+
"https://api.example.com", middlewares=[LogMiddleware()]
|
|
239
|
+
) as client:
|
|
240
|
+
client._client = httpx.Client(
|
|
241
|
+
base_url="https://api.example.com", transport=transport
|
|
242
|
+
)
|
|
243
|
+
client.get("/test")
|
|
244
|
+
assert call_log == ["request", "response"]
|
|
245
|
+
|
|
246
|
+
def test_retry_middleware(self) -> None:
|
|
247
|
+
attempt = 0
|
|
248
|
+
|
|
249
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
250
|
+
nonlocal attempt
|
|
251
|
+
attempt += 1
|
|
252
|
+
if attempt < 3:
|
|
253
|
+
return httpx.Response(503)
|
|
254
|
+
return httpx.Response(200, json={"ok": True})
|
|
255
|
+
|
|
256
|
+
transport = httpx.MockTransport(handler)
|
|
257
|
+
retry_mw = RetryMiddleware(max_retries=3, delay=0.0)
|
|
258
|
+
with ApiClient(
|
|
259
|
+
"https://api.example.com", middlewares=[retry_mw]
|
|
260
|
+
) as client:
|
|
261
|
+
client._client = httpx.Client(
|
|
262
|
+
base_url="https://api.example.com", transport=transport
|
|
263
|
+
)
|
|
264
|
+
resp = client.get("/test")
|
|
265
|
+
assert resp.status_code == 200
|
|
266
|
+
assert attempt == 3
|
|
267
|
+
|
|
268
|
+
def test_retry_exhausted(self) -> None:
|
|
269
|
+
transport = httpx.MockTransport(
|
|
270
|
+
lambda req: httpx.Response(503)
|
|
271
|
+
)
|
|
272
|
+
retry_mw = RetryMiddleware(max_retries=2, delay=0.0)
|
|
273
|
+
with ApiClient(
|
|
274
|
+
"https://api.example.com", middlewares=[retry_mw]
|
|
275
|
+
) as client:
|
|
276
|
+
client._client = httpx.Client(
|
|
277
|
+
base_url="https://api.example.com", transport=transport
|
|
278
|
+
)
|
|
279
|
+
resp = client.get("/test")
|
|
280
|
+
assert resp.status_code == 503
|
|
281
|
+
|
|
282
|
+
def test_paginate_cursor(self) -> None:
|
|
283
|
+
page = 0
|
|
284
|
+
|
|
285
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
286
|
+
nonlocal page
|
|
287
|
+
page += 1
|
|
288
|
+
if page == 1:
|
|
289
|
+
return httpx.Response(
|
|
290
|
+
200, json={"results": [1, 2], "next_cursor": "abc"}
|
|
291
|
+
)
|
|
292
|
+
return httpx.Response(200, json={"results": [3], "next_cursor": None})
|
|
293
|
+
|
|
294
|
+
transport = httpx.MockTransport(handler)
|
|
295
|
+
with ApiClient("https://api.example.com") as client:
|
|
296
|
+
client._client = httpx.Client(
|
|
297
|
+
base_url="https://api.example.com", transport=transport
|
|
298
|
+
)
|
|
299
|
+
all_results: list[Any] = []
|
|
300
|
+
for batch in client.paginate("/items", CursorPaginator()):
|
|
301
|
+
all_results.extend(batch)
|
|
302
|
+
assert all_results == [1, 2, 3]
|
|
303
|
+
|
|
304
|
+
def test_paginate_empty(self) -> None:
|
|
305
|
+
transport = httpx.MockTransport(
|
|
306
|
+
lambda req: httpx.Response(
|
|
307
|
+
200, json={"results": [], "next_cursor": None}
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
with ApiClient("https://api.example.com") as client:
|
|
311
|
+
client._client = httpx.Client(
|
|
312
|
+
base_url="https://api.example.com", transport=transport
|
|
313
|
+
)
|
|
314
|
+
pages = list(client.paginate("/items", CursorPaginator()))
|
|
315
|
+
assert pages == []
|