apitally 0.16.1__tar.gz → 0.16.3__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.
- {apitally-0.16.1 → apitally-0.16.3}/.github/workflows/tests.yaml +1 -18
- {apitally-0.16.1 → apitally-0.16.3}/.pre-commit-config.yaml +2 -2
- {apitally-0.16.1 → apitally-0.16.3}/PKG-INFO +4 -4
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/client_asyncio.py +1 -7
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/client_threading.py +1 -3
- {apitally-0.16.1 → apitally-0.16.3}/apitally/starlette.py +40 -20
- {apitally-0.16.1 → apitally-0.16.3}/pyproject.toml +2 -2
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_asyncio.py +3 -2
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_threading.py +3 -1
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_fastapi.py +0 -2
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_litestar.py +0 -1
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_starlette.py +103 -104
- {apitally-0.16.1 → apitally-0.16.3}/uv.lock +1574 -1574
- {apitally-0.16.1 → apitally-0.16.3}/.github/workflows/publish.yaml +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/.github/workflows/summary.yaml +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/.gitignore +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/LICENSE +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/Makefile +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/README.md +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/__init__.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/blacksheep.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/__init__.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/client_base.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/consumers.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/logging.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/request_logging.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/requests.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/sentry.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/server_errors.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/client/validation_errors.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/common.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/django.py +1 -1
- {apitally-0.16.1 → apitally-0.16.3}/apitally/django_ninja.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/django_rest_framework.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/fastapi.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/flask.py +1 -1
- {apitally-0.16.1 → apitally-0.16.3}/apitally/litestar.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/apitally/py.typed +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/renovate.json +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/__init__.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/conftest.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/constants.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/django_ninja_urls.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/django_rest_framework_urls.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_blacksheep.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_consumers.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_request_logging.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_requests.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_server_errors.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_validation_errors.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_django_ninja.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_django_rest_framework.py +0 -0
- {apitally-0.16.1 → apitally-0.16.3}/tests/test_flask.py +0 -0
@@ -48,11 +48,8 @@ jobs:
|
|
48
48
|
matrix:
|
49
49
|
python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
|
50
50
|
deps:
|
51
|
-
- starlette
|
52
51
|
- fastapi starlette
|
53
|
-
- fastapi==0.
|
54
|
-
- fastapi==0.100.1 starlette
|
55
|
-
- fastapi==0.88.0 starlette
|
52
|
+
- fastapi==0.94.1 starlette
|
56
53
|
- flask
|
57
54
|
- flask==2.3.*
|
58
55
|
- flask==2.0.3 Werkzeug==2.*
|
@@ -61,36 +58,22 @@ jobs:
|
|
61
58
|
- djangorestframework==3.12.* django==3.2.* uritemplate
|
62
59
|
- djangorestframework==3.10.* django==2.2.* uritemplate
|
63
60
|
- django-ninja django
|
64
|
-
- django-ninja==0.22.* django
|
65
61
|
- django-ninja==0.18.0 django
|
66
62
|
- litestar
|
67
|
-
- litestar==2.6.1
|
68
|
-
- litestar==2.3.0
|
69
63
|
- litestar==2.0.1
|
70
64
|
- blacksheep
|
71
|
-
- blacksheep==2.2.0
|
72
65
|
- blacksheep==2.1.0
|
73
66
|
exclude:
|
74
67
|
- python: "3.8"
|
75
68
|
deps: blacksheep
|
76
|
-
- python: "3.8"
|
77
|
-
deps: blacksheep==2.2.0
|
78
69
|
- python: "3.8"
|
79
70
|
deps: blacksheep==2.1.0
|
80
|
-
- python: "3.12"
|
81
|
-
deps: fastapi==0.100.1 starlette
|
82
|
-
- python: "3.12"
|
83
|
-
deps: fastapi==0.87.0 starlette
|
84
71
|
- python: "3.12"
|
85
72
|
deps: djangorestframework==3.12.* django==3.2.* uritemplate
|
86
73
|
- python: "3.12"
|
87
74
|
deps: djangorestframework==3.10.* django==2.2.* uritemplate
|
88
75
|
- python: "3.12"
|
89
76
|
deps: litestar==2.0.1
|
90
|
-
- python: "3.13"
|
91
|
-
deps: fastapi==0.100.1 starlette
|
92
|
-
- python: "3.13"
|
93
|
-
deps: fastapi==0.87.0 starlette
|
94
77
|
- python: "3.13"
|
95
78
|
deps: djangorestframework==3.12.* django==3.2.* uritemplate
|
96
79
|
- python: "3.13"
|
@@ -8,12 +8,12 @@ repos:
|
|
8
8
|
- id: trailing-whitespace
|
9
9
|
- id: mixed-line-ending
|
10
10
|
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
11
|
-
rev: v0.11.
|
11
|
+
rev: v0.11.8
|
12
12
|
hooks:
|
13
13
|
- id: ruff
|
14
14
|
args: ["--fix", "--exit-non-zero-on-fix"]
|
15
15
|
- id: ruff-format
|
16
16
|
- repo: https://github.com/astral-sh/uv-pre-commit
|
17
|
-
rev: 0.
|
17
|
+
rev: 0.7.2
|
18
18
|
hooks:
|
19
19
|
- id: uv-lock
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: apitally
|
3
|
-
Version: 0.16.
|
3
|
+
Version: 0.16.3
|
4
4
|
Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette, Litestar and BlackSheep.
|
5
5
|
Project-URL: Homepage, https://apitally.io
|
6
6
|
Project-URL: Documentation, https://docs.apitally.io
|
@@ -49,9 +49,9 @@ Requires-Dist: inflection>=0.5.1; extra == 'django-rest-framework'
|
|
49
49
|
Requires-Dist: requests>=2.26.0; extra == 'django-rest-framework'
|
50
50
|
Requires-Dist: uritemplate>=3.0.0; extra == 'django-rest-framework'
|
51
51
|
Provides-Extra: fastapi
|
52
|
-
Requires-Dist: fastapi>=0.
|
52
|
+
Requires-Dist: fastapi>=0.94.1; extra == 'fastapi'
|
53
53
|
Requires-Dist: httpx>=0.22.0; extra == 'fastapi'
|
54
|
-
Requires-Dist: starlette<1.0.0,>=0.
|
54
|
+
Requires-Dist: starlette<1.0.0,>=0.26.1; extra == 'fastapi'
|
55
55
|
Provides-Extra: flask
|
56
56
|
Requires-Dist: flask>=2.0.0; extra == 'flask'
|
57
57
|
Requires-Dist: requests>=2.26.0; extra == 'flask'
|
@@ -62,7 +62,7 @@ Provides-Extra: sentry
|
|
62
62
|
Requires-Dist: sentry-sdk>=2.2.0; extra == 'sentry'
|
63
63
|
Provides-Extra: starlette
|
64
64
|
Requires-Dist: httpx>=0.22.0; extra == 'starlette'
|
65
|
-
Requires-Dist: starlette<1.0.0,>=0.
|
65
|
+
Requires-Dist: starlette<1.0.0,>=0.26.1; extra == 'starlette'
|
66
66
|
Description-Content-Type: text/markdown
|
67
67
|
|
68
68
|
<p align="center">
|
@@ -41,7 +41,6 @@ class ApitallyClient(ApitallyClientBase):
|
|
41
41
|
self._stop_sync_loop = False
|
42
42
|
self._sync_loop_task: Optional[asyncio.Task] = None
|
43
43
|
self._sync_data_queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue()
|
44
|
-
self._set_startup_data_task: Optional[asyncio.Task] = None
|
45
44
|
|
46
45
|
def get_http_client(self) -> httpx.AsyncClient:
|
47
46
|
if httpx.__version__ >= "0.26.0":
|
@@ -67,7 +66,7 @@ class ApitallyClient(ApitallyClientBase):
|
|
67
66
|
try:
|
68
67
|
async with self.get_http_client() as client:
|
69
68
|
tasks = [self.send_sync_data(client), self.send_log_data(client)]
|
70
|
-
if not self._startup_data_sent
|
69
|
+
if not self._startup_data_sent:
|
71
70
|
tasks.append(self.send_startup_data(client))
|
72
71
|
await asyncio.gather(*tasks)
|
73
72
|
last_sync_time = now
|
@@ -92,11 +91,6 @@ class ApitallyClient(ApitallyClientBase):
|
|
92
91
|
def set_startup_data(self, data: Dict[str, Any]) -> None:
|
93
92
|
self._startup_data_sent = False
|
94
93
|
self._startup_data = self.add_uuids_to_data(data)
|
95
|
-
self._set_startup_data_task = asyncio.create_task(self._set_startup_data())
|
96
|
-
|
97
|
-
async def _set_startup_data(self) -> None:
|
98
|
-
async with self.get_http_client() as client:
|
99
|
-
await self.send_startup_data(client)
|
100
94
|
|
101
95
|
async def send_startup_data(self, client: httpx.AsyncClient) -> None:
|
102
96
|
if self._startup_data is not None:
|
@@ -80,7 +80,7 @@ class ApitallyClient(ApitallyClientBase):
|
|
80
80
|
if (now - last_sync_time) >= self.sync_interval:
|
81
81
|
try:
|
82
82
|
with requests.Session() as session:
|
83
|
-
if not self._startup_data_sent
|
83
|
+
if not self._startup_data_sent:
|
84
84
|
self.send_startup_data(session)
|
85
85
|
self.send_sync_data(session)
|
86
86
|
self.send_log_data(session)
|
@@ -105,8 +105,6 @@ class ApitallyClient(ApitallyClientBase):
|
|
105
105
|
def set_startup_data(self, data: Dict[str, Any]) -> None:
|
106
106
|
self._startup_data_sent = False
|
107
107
|
self._startup_data = self.add_uuids_to_data(data)
|
108
|
-
with requests.Session() as session:
|
109
|
-
self.send_startup_data(session)
|
110
108
|
|
111
109
|
def send_startup_data(self, session: requests.Session) -> None:
|
112
110
|
if self._startup_data is not None:
|
@@ -1,17 +1,18 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import asyncio
|
4
3
|
import time
|
5
|
-
from
|
4
|
+
from contextlib import asynccontextmanager
|
5
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
|
6
6
|
from warnings import warn
|
7
7
|
|
8
8
|
from httpx import HTTPStatusError, Proxy
|
9
|
+
from starlette.applications import Starlette
|
9
10
|
from starlette.datastructures import Headers
|
10
11
|
from starlette.requests import Request
|
11
12
|
from starlette.routing import BaseRoute, Match, Router
|
12
13
|
from starlette.schemas import EndpointInfo, SchemaGenerator
|
13
14
|
from starlette.testclient import TestClient
|
14
|
-
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
15
|
+
from starlette.types import ASGIApp, Lifespan, Message, Receive, Scope, Send
|
15
16
|
|
16
17
|
from apitally.client.client_asyncio import ApitallyClient
|
17
18
|
from apitally.client.consumers import Consumer as ApitallyConsumer
|
@@ -40,6 +41,8 @@ class ApitallyMiddleware:
|
|
40
41
|
proxy: Optional[Union[str, Proxy]] = None,
|
41
42
|
) -> None:
|
42
43
|
self.app = app
|
44
|
+
self.app_version = app_version
|
45
|
+
self.openapi_url = openapi_url
|
43
46
|
self.identify_consumer_callback = identify_consumer_callback
|
44
47
|
self.client = ApitallyClient(
|
45
48
|
client_id=client_id,
|
@@ -47,10 +50,6 @@ class ApitallyMiddleware:
|
|
47
50
|
request_logging_config=request_logging_config,
|
48
51
|
proxy=proxy,
|
49
52
|
)
|
50
|
-
self.client.start_sync_loop()
|
51
|
-
self._delayed_set_startup_data_task: Optional[asyncio.Task] = None
|
52
|
-
self.delayed_set_startup_data(app_version, openapi_url)
|
53
|
-
_register_shutdown_handler(app, self.client.handle_shutdown)
|
54
53
|
|
55
54
|
self.capture_request_body = (
|
56
55
|
self.client.request_logger.config.enabled and self.client.request_logger.config.log_request_body
|
@@ -59,17 +58,16 @@ class ApitallyMiddleware:
|
|
59
58
|
self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
|
60
59
|
)
|
61
60
|
|
62
|
-
|
63
|
-
|
64
|
-
self.
|
61
|
+
_inject_lifespan_handlers(
|
62
|
+
app,
|
63
|
+
on_startup=self.on_startup,
|
64
|
+
on_shutdown=self.client.handle_shutdown,
|
65
65
|
)
|
66
66
|
|
67
|
-
async def
|
68
|
-
self, app_version
|
69
|
-
) -> None:
|
70
|
-
await asyncio.sleep(1.0) # Short delay to allow app routes to be registered first
|
71
|
-
data = _get_startup_data(self.app, app_version, openapi_url)
|
67
|
+
async def on_startup(self) -> None:
|
68
|
+
data = _get_startup_data(self.app, app_version=self.app_version, openapi_url=self.openapi_url)
|
72
69
|
self.client.set_startup_data(data)
|
70
|
+
self.client.start_sync_loop()
|
73
71
|
|
74
72
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
75
73
|
if self.client.enabled and scope["type"] == "http" and scope["method"] != "OPTIONS":
|
@@ -296,8 +294,30 @@ def _get_routes(app: Union[ASGIApp, Router]) -> List[BaseRoute]:
|
|
296
294
|
return [] # pragma: no cover
|
297
295
|
|
298
296
|
|
299
|
-
def
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
297
|
+
def _inject_lifespan_handlers(
|
298
|
+
app: Union[ASGIApp, Router],
|
299
|
+
on_startup: Callable[[], Awaitable[Any]],
|
300
|
+
on_shutdown: Callable[[], Awaitable[Any]],
|
301
|
+
) -> None:
|
302
|
+
"""
|
303
|
+
Ensures the given startup and shutdown functions are called as part of the app's lifespan context manager.
|
304
|
+
"""
|
305
|
+
router = app
|
306
|
+
while not isinstance(router, Router) and hasattr(router, "app"):
|
307
|
+
router = router.app
|
308
|
+
if not isinstance(router, Router):
|
309
|
+
raise TypeError("app must be a Starlette or Router instance")
|
310
|
+
|
311
|
+
lifespan: Optional[Lifespan] = getattr(router, "lifespan_context", None)
|
312
|
+
|
313
|
+
@asynccontextmanager
|
314
|
+
async def wrapped_lifespan(app: Starlette):
|
315
|
+
await on_startup()
|
316
|
+
if lifespan is not None:
|
317
|
+
async with lifespan(app):
|
318
|
+
yield
|
319
|
+
else:
|
320
|
+
yield
|
321
|
+
await on_shutdown()
|
322
|
+
|
323
|
+
router.lifespan_context = wrapped_lifespan
|
@@ -49,11 +49,11 @@ django_rest_framework = [
|
|
49
49
|
"inflection>=0.5.1", # required for schema generation
|
50
50
|
"requests>=2.26.0",
|
51
51
|
]
|
52
|
-
fastapi = ["fastapi>=0.
|
52
|
+
fastapi = ["fastapi>=0.94.1", "starlette>=0.26.1,<1.0.0", "httpx>=0.22.0"]
|
53
53
|
flask = ["flask>=2.0.0", "requests>=2.26.0"]
|
54
54
|
litestar = ["litestar>=2.0.0", "httpx>=0.22.0"]
|
55
55
|
sentry = ["sentry-sdk>=2.2.0"]
|
56
|
-
starlette = ["starlette>=0.
|
56
|
+
starlette = ["starlette>=0.26.1,<1.0.0", "httpx>=0.22.0"]
|
57
57
|
|
58
58
|
[project.urls]
|
59
59
|
Homepage = "https://apitally.io"
|
@@ -156,13 +156,14 @@ async def test_send_log_data(client: ApitallyClient, httpx_mock: HTTPXMock):
|
|
156
156
|
assert len(client.request_logger.file_deque) == 0
|
157
157
|
|
158
158
|
|
159
|
-
async def
|
159
|
+
async def test_send_startup_data(client: ApitallyClient, httpx_mock: HTTPXMock):
|
160
160
|
from apitally.client.client_base import HUB_BASE_URL, HUB_VERSION
|
161
161
|
|
162
162
|
httpx_mock.add_response()
|
163
163
|
data = {"paths": [], "client_version": "1.0.0", "starlette_version": "0.28.0", "python_version": "3.11.4"}
|
164
164
|
client.set_startup_data(data)
|
165
|
-
|
165
|
+
async with client.get_http_client() as http_client:
|
166
|
+
await client.send_startup_data(client=http_client)
|
166
167
|
|
167
168
|
request = httpx_mock.get_request(url=f"{HUB_BASE_URL}/{HUB_VERSION}/{CLIENT_ID}/{ENV}/startup")
|
168
169
|
assert request is not None
|
@@ -141,12 +141,14 @@ def test_send_log_data(client: ApitallyClient, requests_mock: Mocker):
|
|
141
141
|
assert len(client.request_logger.file_deque) == 0
|
142
142
|
|
143
143
|
|
144
|
-
def
|
144
|
+
def test_send_startup_data(client: ApitallyClient, requests_mock: Mocker):
|
145
145
|
from apitally.client.client_base import HUB_BASE_URL, HUB_VERSION
|
146
146
|
|
147
147
|
mock = requests_mock.register_uri("POST", f"{HUB_BASE_URL}/{HUB_VERSION}/{CLIENT_ID}/{ENV}/startup")
|
148
148
|
data = {"paths": [], "client_version": "1.0.0", "starlette_version": "0.28.0", "python_version": "3.11.4"}
|
149
149
|
client.set_startup_data(data)
|
150
|
+
with requests.Session() as session:
|
151
|
+
client.send_startup_data(session)
|
150
152
|
|
151
153
|
assert len(mock.request_history) == 1
|
152
154
|
request_data = mock.request_history[0].json()
|
@@ -30,8 +30,6 @@ def app(module_mocker: MockerFixture) -> FastAPI:
|
|
30
30
|
|
31
31
|
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient._instance", None)
|
32
32
|
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.start_sync_loop")
|
33
|
-
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.set_startup_data")
|
34
|
-
module_mocker.patch("apitally.starlette.ApitallyMiddleware.delayed_set_startup_data")
|
35
33
|
|
36
34
|
def identify_consumer(request: Request) -> Optional[str]:
|
37
35
|
if consumer := request.query_params.get("consumer"):
|
@@ -37,7 +37,6 @@ async def app(module_mocker: MockerFixture) -> Litestar:
|
|
37
37
|
|
38
38
|
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient._instance", None)
|
39
39
|
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.start_sync_loop")
|
40
|
-
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.set_startup_data")
|
41
40
|
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.handle_shutdown", mocked_handle_shutdown)
|
42
41
|
|
43
42
|
@get("/foo")
|
@@ -28,8 +28,7 @@ if TYPE_CHECKING:
|
|
28
28
|
async def app(request: FixtureRequest, module_mocker: MockerFixture) -> Starlette:
|
29
29
|
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient._instance", None)
|
30
30
|
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.start_sync_loop")
|
31
|
-
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.
|
32
|
-
module_mocker.patch("apitally.starlette.ApitallyMiddleware.delayed_set_startup_data")
|
31
|
+
module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.handle_shutdown")
|
33
32
|
if request.param == "starlette":
|
34
33
|
return get_starlette_app()
|
35
34
|
elif request.param == "fastapi":
|
@@ -184,35 +183,35 @@ def test_middleware_requests_ok(app: Starlette, mocker: MockerFixture):
|
|
184
183
|
from starlette.testclient import TestClient
|
185
184
|
|
186
185
|
mock = mocker.patch("apitally.client.requests.RequestCounter.add_request")
|
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
|
-
|
186
|
+
|
187
|
+
with TestClient(app) as client:
|
188
|
+
response = client.get("/api/foo")
|
189
|
+
assert response.status_code == 200
|
190
|
+
mock.assert_called_once()
|
191
|
+
assert mock.call_args is not None
|
192
|
+
assert mock.call_args.kwargs["consumer"] == "test"
|
193
|
+
assert mock.call_args.kwargs["method"] == "GET"
|
194
|
+
assert mock.call_args.kwargs["path"] == "/api/foo"
|
195
|
+
assert mock.call_args.kwargs["status_code"] == 200
|
196
|
+
assert mock.call_args.kwargs["response_time"] > 0
|
197
|
+
|
198
|
+
response = client.get("/api/foo/123")
|
199
|
+
assert response.status_code == 200
|
200
|
+
assert mock.call_count == 2
|
201
|
+
assert mock.call_args is not None
|
202
|
+
assert mock.call_args.kwargs["path"] == "/api/foo/{bar}"
|
203
|
+
|
204
|
+
response = client.post("/api/bar")
|
205
|
+
assert response.status_code == 200
|
206
|
+
assert mock.call_count == 3
|
207
|
+
assert mock.call_args is not None
|
208
|
+
assert mock.call_args.kwargs["method"] == "POST"
|
209
|
+
|
210
|
+
response = client.get("/stream")
|
211
|
+
assert response.status_code == 200
|
212
|
+
assert mock.call_count == 4
|
213
|
+
assert mock.call_args is not None
|
214
|
+
assert mock.call_args.kwargs["response_size"] == 6
|
216
215
|
|
217
216
|
|
218
217
|
def test_middleware_requests_error(app: Starlette, mocker: MockerFixture):
|
@@ -220,60 +219,60 @@ def test_middleware_requests_error(app: Starlette, mocker: MockerFixture):
|
|
220
219
|
|
221
220
|
mock1 = mocker.patch("apitally.client.requests.RequestCounter.add_request")
|
222
221
|
mock2 = mocker.patch("apitally.client.server_errors.ServerErrorCounter.add_server_error")
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
222
|
+
|
223
|
+
with TestClient(app, raise_server_exceptions=False) as client:
|
224
|
+
response = client.post("/api/baz")
|
225
|
+
assert response.status_code == 500
|
226
|
+
mock1.assert_called_once()
|
227
|
+
assert mock1.call_args is not None
|
228
|
+
assert mock1.call_args.kwargs["method"] == "POST"
|
229
|
+
assert mock1.call_args.kwargs["path"] == "/api/baz"
|
230
|
+
assert mock1.call_args.kwargs["status_code"] == 500
|
231
|
+
assert mock1.call_args.kwargs["response_time"] > 0
|
232
|
+
|
233
|
+
mock2.assert_called_once()
|
234
|
+
assert mock2.call_args is not None
|
235
|
+
exception = mock2.call_args.kwargs["exception"]
|
236
|
+
assert isinstance(exception, ValueError)
|
237
|
+
|
238
|
+
# Throws a ValueError in a background task, but returns 200
|
239
|
+
response = client.post("/test/task")
|
240
|
+
assert response.status_code == 200
|
241
|
+
assert mock1.call_count == 2
|
242
|
+
assert mock1.call_args is not None
|
243
|
+
assert mock1.call_args.kwargs["status_code"] == 200
|
244
|
+
mock2.assert_called_once() # Not called again
|
246
245
|
|
247
246
|
|
248
247
|
def test_middleware_requests_unhandled(app: Starlette, mocker: MockerFixture):
|
249
248
|
from starlette.testclient import TestClient
|
250
249
|
|
251
250
|
mock = mocker.patch("apitally.client.requests.RequestCounter.add_request")
|
252
|
-
client = TestClient(app)
|
253
251
|
|
254
|
-
|
255
|
-
|
256
|
-
|
252
|
+
with TestClient(app) as client:
|
253
|
+
response = client.post("/xxx")
|
254
|
+
assert response.status_code == 404
|
255
|
+
mock.assert_not_called()
|
257
256
|
|
258
257
|
|
259
258
|
def test_middleware_validation_error(app: Starlette, mocker: MockerFixture):
|
260
259
|
from starlette.testclient import TestClient
|
261
260
|
|
262
261
|
mock = mocker.patch("apitally.client.validation_errors.ValidationErrorCounter.add_validation_errors")
|
263
|
-
client = TestClient(app)
|
264
262
|
|
265
|
-
|
266
|
-
|
267
|
-
|
263
|
+
with TestClient(app) as client:
|
264
|
+
# Validation error as foo must be an integer
|
265
|
+
response = client.get("/api/val?foo=bar")
|
266
|
+
assert response.status_code == 422
|
268
267
|
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
268
|
+
# FastAPI only
|
269
|
+
if response.headers["Content-Type"] == "application/json":
|
270
|
+
mock.assert_called_once()
|
271
|
+
assert mock.call_args is not None
|
272
|
+
assert mock.call_args.kwargs["method"] == "GET"
|
273
|
+
assert mock.call_args.kwargs["path"] == "/api/val"
|
274
|
+
assert len(mock.call_args.kwargs["detail"]) == 1
|
275
|
+
assert mock.call_args.kwargs["detail"][0]["loc"] == ["query", "foo"]
|
277
276
|
|
278
277
|
|
279
278
|
def test_middleware_request_logging(app: Starlette, mocker: MockerFixture):
|
@@ -282,40 +281,40 @@ def test_middleware_request_logging(app: Starlette, mocker: MockerFixture):
|
|
282
281
|
from apitally.client.request_logging import BODY_TOO_LARGE
|
283
282
|
|
284
283
|
mock = mocker.patch("apitally.client.request_logging.RequestLogger.log_request")
|
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
|
-
|
284
|
+
|
285
|
+
with TestClient(app) as client:
|
286
|
+
response = client.get("/api/foo/123?foo=bar", headers={"Test-Header": "test"})
|
287
|
+
assert response.status_code == 200
|
288
|
+
mock.assert_called_once()
|
289
|
+
assert mock.call_args is not None
|
290
|
+
assert mock.call_args.kwargs["request"]["method"] == "GET"
|
291
|
+
assert mock.call_args.kwargs["request"]["path"] == "/api/foo/{bar}"
|
292
|
+
assert mock.call_args.kwargs["request"]["url"] == "http://testserver/api/foo/123?foo=bar"
|
293
|
+
assert ("test-header", "test") in mock.call_args.kwargs["request"]["headers"]
|
294
|
+
assert mock.call_args.kwargs["request"]["consumer"] == "test"
|
295
|
+
assert mock.call_args.kwargs["response"]["status_code"] == 200
|
296
|
+
assert mock.call_args.kwargs["response"]["response_time"] > 0
|
297
|
+
assert ("content-type", "text/plain; charset=utf-8") in mock.call_args.kwargs["response"]["headers"]
|
298
|
+
assert mock.call_args.kwargs["response"]["size"] > 0
|
299
|
+
assert mock.call_args.kwargs["response"]["body"] == b"foo: 123"
|
300
|
+
|
301
|
+
response = client.post("/api/bar", content=b"foo")
|
302
|
+
assert response.status_code == 200
|
303
|
+
assert mock.call_count == 2
|
304
|
+
assert mock.call_args is not None
|
305
|
+
assert mock.call_args.kwargs["request"]["method"] == "POST"
|
306
|
+
assert mock.call_args.kwargs["request"]["path"] == "/api/bar"
|
307
|
+
assert mock.call_args.kwargs["request"]["url"] == "http://testserver/api/bar"
|
308
|
+
assert mock.call_args.kwargs["request"]["body"] == b"foo"
|
309
|
+
assert mock.call_args.kwargs["response"]["body"] == b"bar: foo"
|
310
|
+
|
311
|
+
mocker.patch("apitally.starlette.MAX_BODY_SIZE", 2)
|
312
|
+
response = client.post("/api/bar", content=b"foo")
|
313
|
+
assert response.status_code == 200
|
314
|
+
assert mock.call_count == 3
|
315
|
+
assert mock.call_args is not None
|
316
|
+
assert mock.call_args.kwargs["request"]["body"] == BODY_TOO_LARGE
|
317
|
+
assert mock.call_args.kwargs["response"]["body"] == BODY_TOO_LARGE
|
319
318
|
|
320
319
|
|
321
320
|
def test_get_startup_data(app: Starlette, mocker: MockerFixture):
|