apitally 0.11.3__py3-none-any.whl → 0.12.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.
- apitally/__init__.py +1 -1
- apitally/litestar.py +21 -4
- apitally/starlette.py +83 -68
- {apitally-0.11.3.dist-info → apitally-0.12.1.dist-info}/METADATA +1 -1
- {apitally-0.11.3.dist-info → apitally-0.12.1.dist-info}/RECORD +7 -7
- {apitally-0.11.3.dist-info → apitally-0.12.1.dist-info}/LICENSE +0 -0
- {apitally-0.11.3.dist-info → apitally-0.12.1.dist-info}/WHEEL +0 -0
apitally/__init__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = "0.
|
1
|
+
__version__ = "0.12.1"
|
apitally/litestar.py
CHANGED
@@ -72,16 +72,31 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
72
72
|
response_time = 0.0
|
73
73
|
response_headers = Headers()
|
74
74
|
response_body = b""
|
75
|
+
response_size = 0
|
76
|
+
response_chunked = False
|
75
77
|
start_time = time.perf_counter()
|
76
78
|
|
77
79
|
async def send_wrapper(message: Message) -> None:
|
78
|
-
nonlocal
|
80
|
+
nonlocal \
|
81
|
+
response_time, \
|
82
|
+
response_status, \
|
83
|
+
response_headers, \
|
84
|
+
response_body, \
|
85
|
+
response_size, \
|
86
|
+
response_chunked
|
79
87
|
if message["type"] == "http.response.start":
|
80
88
|
response_time = time.perf_counter() - start_time
|
81
89
|
response_status = message["status"]
|
82
90
|
response_headers = Headers(message["headers"])
|
83
|
-
|
84
|
-
|
91
|
+
response_chunked = (
|
92
|
+
response_headers.get("Transfer-Encoding") == "chunked"
|
93
|
+
or "Content-Length" not in response_headers
|
94
|
+
)
|
95
|
+
elif message["type"] == "http.response.body":
|
96
|
+
if response_chunked:
|
97
|
+
response_size += len(message.get("body", b""))
|
98
|
+
if response_status == 400:
|
99
|
+
response_body += message.get("body", b"")
|
85
100
|
await send(message)
|
86
101
|
|
87
102
|
await app(scope, receive, send_wrapper)
|
@@ -91,6 +106,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
91
106
|
response_time=response_time,
|
92
107
|
response_headers=response_headers,
|
93
108
|
response_body=response_body,
|
109
|
+
response_size=response_size,
|
94
110
|
)
|
95
111
|
else:
|
96
112
|
await app(scope, receive, send) # pragma: no cover
|
@@ -104,6 +120,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
104
120
|
response_time: float,
|
105
121
|
response_headers: Headers,
|
106
122
|
response_body: bytes,
|
123
|
+
response_size: int = 0,
|
107
124
|
) -> None:
|
108
125
|
if response_status < 100 or not request.route_handler.paths:
|
109
126
|
return # pragma: no cover
|
@@ -120,7 +137,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
120
137
|
status_code=response_status,
|
121
138
|
response_time=response_time,
|
122
139
|
request_size=request.headers.get("Content-Length"),
|
123
|
-
response_size=response_headers.get("Content-Length"),
|
140
|
+
response_size=response_size or response_headers.get("Content-Length"),
|
124
141
|
)
|
125
142
|
if response_status == 400 and response_body and len(response_body) < 4096:
|
126
143
|
with contextlib.suppress(json.JSONDecodeError):
|
apitally/starlette.py
CHANGED
@@ -1,35 +1,29 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import asyncio
|
4
|
+
import contextlib
|
4
5
|
import json
|
5
6
|
import time
|
6
|
-
from typing import
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
7
8
|
from warnings import warn
|
8
9
|
|
9
10
|
from httpx import HTTPStatusError
|
10
|
-
from starlette.
|
11
|
-
from starlette.
|
11
|
+
from starlette.datastructures import Headers
|
12
|
+
from starlette.requests import Request
|
12
13
|
from starlette.routing import BaseRoute, Match, Router
|
13
14
|
from starlette.schemas import EndpointInfo, SchemaGenerator
|
14
|
-
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
|
15
15
|
from starlette.testclient import TestClient
|
16
|
-
from starlette.types import ASGIApp
|
16
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
17
17
|
|
18
18
|
from apitally.client.asyncio import ApitallyClient
|
19
19
|
from apitally.client.base import Consumer as ApitallyConsumer
|
20
20
|
from apitally.common import get_versions
|
21
21
|
|
22
22
|
|
23
|
-
if TYPE_CHECKING:
|
24
|
-
from starlette.middleware.base import RequestResponseEndpoint
|
25
|
-
from starlette.requests import Request
|
26
|
-
from starlette.responses import Response
|
27
|
-
|
28
|
-
|
29
23
|
__all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
|
30
24
|
|
31
25
|
|
32
|
-
class ApitallyMiddleware
|
26
|
+
class ApitallyMiddleware:
|
33
27
|
def __init__(
|
34
28
|
self,
|
35
29
|
app: ASGIApp,
|
@@ -40,6 +34,7 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
40
34
|
filter_unhandled_paths: bool = True,
|
41
35
|
identify_consumer_callback: Optional[Callable[[Request], Union[str, ApitallyConsumer, None]]] = None,
|
42
36
|
) -> None:
|
37
|
+
self.app = app
|
43
38
|
self.filter_unhandled_paths = filter_unhandled_paths
|
44
39
|
self.identify_consumer_callback = identify_consumer_callback
|
45
40
|
self.client = ApitallyClient(client_id=client_id, env=env)
|
@@ -47,7 +42,6 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
47
42
|
self._delayed_set_startup_data_task: Optional[asyncio.Task] = None
|
48
43
|
self.delayed_set_startup_data(app_version, openapi_url)
|
49
44
|
_register_shutdown_handler(app, self.client.handle_shutdown)
|
50
|
-
super().__init__(app)
|
51
45
|
|
52
46
|
def delayed_set_startup_data(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
|
53
47
|
self._delayed_set_startup_data_task = asyncio.create_task(
|
@@ -61,38 +55,77 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
61
55
|
data = _get_startup_data(self.app, app_version, openapi_url)
|
62
56
|
self.client.set_startup_data(data)
|
63
57
|
|
64
|
-
async def
|
65
|
-
|
58
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
59
|
+
if scope["type"] == "http" and scope["method"] != "OPTIONS":
|
60
|
+
request = Request(scope)
|
61
|
+
response_status = 0
|
62
|
+
response_time = 0.0
|
63
|
+
response_headers = Headers()
|
64
|
+
response_body = b""
|
65
|
+
response_size = 0
|
66
|
+
response_chunked = False
|
66
67
|
start_time = time.perf_counter()
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
68
|
+
|
69
|
+
async def send_wrapper(message: Message) -> None:
|
70
|
+
nonlocal \
|
71
|
+
response_time, \
|
72
|
+
response_status, \
|
73
|
+
response_headers, \
|
74
|
+
response_body, \
|
75
|
+
response_size, \
|
76
|
+
response_chunked
|
77
|
+
if message["type"] == "http.response.start":
|
78
|
+
response_time = time.perf_counter() - start_time
|
79
|
+
response_status = message["status"]
|
80
|
+
response_headers = Headers(scope=message)
|
81
|
+
response_chunked = (
|
82
|
+
response_headers.get("Transfer-Encoding") == "chunked"
|
83
|
+
or "Content-Length" not in response_headers
|
84
|
+
)
|
85
|
+
elif message["type"] == "http.response.body":
|
86
|
+
if response_chunked:
|
87
|
+
response_size += len(message.get("body", b""))
|
88
|
+
if response_status == 422:
|
89
|
+
response_body += message.get("body", b"")
|
90
|
+
await send(message)
|
91
|
+
|
92
|
+
try:
|
93
|
+
await self.app(scope, receive, send_wrapper)
|
94
|
+
except BaseException as e:
|
95
|
+
self.add_request(
|
96
|
+
request=request,
|
97
|
+
response_status=500,
|
98
|
+
response_time=time.perf_counter() - start_time,
|
99
|
+
response_headers=response_headers,
|
100
|
+
response_body=response_body,
|
101
|
+
response_size=response_size,
|
102
|
+
exception=e,
|
103
|
+
)
|
104
|
+
raise e from None
|
105
|
+
else:
|
106
|
+
self.add_request(
|
107
|
+
request=request,
|
108
|
+
response_status=response_status,
|
109
|
+
response_time=response_time,
|
110
|
+
response_headers=response_headers,
|
111
|
+
response_body=response_body,
|
112
|
+
response_size=response_size,
|
113
|
+
)
|
77
114
|
else:
|
78
|
-
await self.
|
79
|
-
request=request,
|
80
|
-
response=response,
|
81
|
-
status_code=response.status_code,
|
82
|
-
response_time=time.perf_counter() - start_time,
|
83
|
-
)
|
84
|
-
return response
|
115
|
+
await self.app(scope, receive, send) # pragma: no cover
|
85
116
|
|
86
|
-
|
117
|
+
def add_request(
|
87
118
|
self,
|
88
119
|
request: Request,
|
89
|
-
|
90
|
-
status_code: int,
|
120
|
+
response_status: int,
|
91
121
|
response_time: float,
|
122
|
+
response_headers: Headers,
|
123
|
+
response_body: bytes,
|
124
|
+
response_size: int = 0,
|
92
125
|
exception: Optional[BaseException] = None,
|
93
126
|
) -> None:
|
94
127
|
path_template, is_handled_path = self.get_path_template(request)
|
95
|
-
if
|
128
|
+
if is_handled_path or not self.filter_unhandled_paths:
|
96
129
|
consumer = self.get_consumer(request)
|
97
130
|
consumer_identifier = consumer.identifier if consumer else None
|
98
131
|
self.client.consumer_registry.add_or_update_consumer(consumer)
|
@@ -100,26 +133,23 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
100
133
|
consumer=consumer_identifier,
|
101
134
|
method=request.method,
|
102
135
|
path=path_template,
|
103
|
-
status_code=
|
136
|
+
status_code=response_status,
|
104
137
|
response_time=response_time,
|
105
138
|
request_size=request.headers.get("Content-Length"),
|
106
|
-
response_size=
|
139
|
+
response_size=response_size or response_headers.get("Content-Length"),
|
107
140
|
)
|
108
|
-
if (
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
detail=body["detail"],
|
121
|
-
)
|
122
|
-
if status_code == 500 and exception is not None:
|
141
|
+
if response_status == 422 and response_body and response_headers.get("Content-Type") == "application/json":
|
142
|
+
with contextlib.suppress(json.JSONDecodeError):
|
143
|
+
body = json.loads(response_body)
|
144
|
+
if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
|
145
|
+
# Log FastAPI / Pydantic validation errors
|
146
|
+
self.client.validation_error_counter.add_validation_errors(
|
147
|
+
consumer=consumer_identifier,
|
148
|
+
method=request.method,
|
149
|
+
path=path_template,
|
150
|
+
detail=body["detail"],
|
151
|
+
)
|
152
|
+
if response_status == 500 and exception is not None:
|
123
153
|
self.client.server_error_counter.add_server_error(
|
124
154
|
consumer=consumer_identifier,
|
125
155
|
method=request.method,
|
@@ -127,21 +157,6 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
127
157
|
exception=exception,
|
128
158
|
)
|
129
159
|
|
130
|
-
@staticmethod
|
131
|
-
async def get_response_json(response: Response) -> Any:
|
132
|
-
if hasattr(response, "body"):
|
133
|
-
try:
|
134
|
-
return json.loads(response.body)
|
135
|
-
except json.JSONDecodeError: # pragma: no cover
|
136
|
-
return None
|
137
|
-
elif hasattr(response, "body_iterator"):
|
138
|
-
try:
|
139
|
-
response_body = [section async for section in response.body_iterator]
|
140
|
-
response.body_iterator = iterate_in_threadpool(iter(response_body))
|
141
|
-
return json.loads(b"".join(response_body))
|
142
|
-
except json.JSONDecodeError: # pragma: no cover
|
143
|
-
return None
|
144
|
-
|
145
160
|
@staticmethod
|
146
161
|
def get_path_template(request: Request) -> Tuple[str, bool]:
|
147
162
|
for route in request.app.routes:
|
@@ -1,4 +1,4 @@
|
|
1
|
-
apitally/__init__.py,sha256=
|
1
|
+
apitally/__init__.py,sha256=PAuBI8I6F9Yu_86XjI2yaWn8QmCd9ZvK7tkZLWvEg-Q,23
|
2
2
|
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
3
|
apitally/client/asyncio.py,sha256=Y5sbRLRnJCIJx9VQ2DGgQsYNKGvURV2U1y3VxHuPhgQ,4874
|
4
4
|
apitally/client/base.py,sha256=v4LSOYNIOoeL3KTVyBlXBY5LCXc79Lvu9yK5Y_KILSQ,15442
|
@@ -10,10 +10,10 @@ apitally/django_ninja.py,sha256=dqQtnz2s8YWYHCwvkK5BjokjvpZJpPNhP0vng4kFtrQ,120
|
|
10
10
|
apitally/django_rest_framework.py,sha256=dqQtnz2s8YWYHCwvkK5BjokjvpZJpPNhP0vng4kFtrQ,120
|
11
11
|
apitally/fastapi.py,sha256=hEyYZsvIaA3OXZSSFdey5iqeEjfBPHgfNbyX8pLm7GI,123
|
12
12
|
apitally/flask.py,sha256=7TJIoAT91-bR_7gZkL0clDk-Whl-V21hbo4nASaDmB4,6447
|
13
|
-
apitally/litestar.py,sha256=
|
13
|
+
apitally/litestar.py,sha256=O9bSzwJC-dN6ukRqyVNYBhUqxEpzie-bR2bFojcvvMI,9547
|
14
14
|
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
15
|
-
apitally/starlette.py,sha256=
|
16
|
-
apitally-0.
|
17
|
-
apitally-0.
|
18
|
-
apitally-0.
|
19
|
-
apitally-0.
|
15
|
+
apitally/starlette.py,sha256=qmdcAlypqACd4yL_B_5W0ykpkWJaf1YcCAW1yDxaS0c,9615
|
16
|
+
apitally-0.12.1.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
17
|
+
apitally-0.12.1.dist-info/METADATA,sha256=Fibm2KONAkF7Omu-bpxBWvzvhRbjYdtcN9I7h1oVMbg,6994
|
18
|
+
apitally-0.12.1.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
|
19
|
+
apitally-0.12.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|