apitally 0.16.2__py3-none-any.whl → 0.17.0__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/django.py +111 -51
- apitally/starlette.py +37 -10
- {apitally-0.16.2.dist-info → apitally-0.17.0.dist-info}/METADATA +4 -4
- {apitally-0.16.2.dist-info → apitally-0.17.0.dist-info}/RECORD +6 -6
- {apitally-0.16.2.dist-info → apitally-0.17.0.dist-info}/WHEEL +0 -0
- {apitally-0.16.2.dist-info → apitally-0.17.0.dist-info}/licenses/LICENSE +0 -0
apitally/django.py
CHANGED
@@ -10,8 +10,10 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Unio
|
|
10
10
|
from warnings import warn
|
11
11
|
|
12
12
|
from django.conf import settings
|
13
|
+
from django.contrib.admindocs.views import extract_views_from_urlpatterns, simplify_regex
|
13
14
|
from django.urls import URLPattern, URLResolver, get_resolver
|
14
15
|
from django.utils.module_loading import import_string
|
16
|
+
from django.views.generic.base import View
|
15
17
|
|
16
18
|
from apitally.client.client_threading import ApitallyClient
|
17
19
|
from apitally.client.consumers import Consumer as ApitallyConsumer
|
@@ -41,6 +43,7 @@ class ApitallyMiddlewareConfig:
|
|
41
43
|
request_logging_config: Optional[RequestLoggingConfig]
|
42
44
|
app_version: Optional[str]
|
43
45
|
identify_consumer_callback: Optional[Callable[[HttpRequest], Union[str, ApitallyConsumer, None]]]
|
46
|
+
include_django_views: bool
|
44
47
|
urlconfs: List[Optional[str]]
|
45
48
|
proxy: Optional[str]
|
46
49
|
|
@@ -53,6 +56,7 @@ class ApitallyMiddleware:
|
|
53
56
|
self.drf_available = _check_import("rest_framework")
|
54
57
|
self.drf_endpoint_enumerator = None
|
55
58
|
self.ninja_available = _check_import("ninja")
|
59
|
+
self.include_django_views = False
|
56
60
|
self.callbacks = set()
|
57
61
|
|
58
62
|
if self.config is None:
|
@@ -68,6 +72,9 @@ class ApitallyMiddleware:
|
|
68
72
|
self.callbacks.update(_get_drf_callbacks(self.config.urlconfs))
|
69
73
|
if self.ninja_available and None not in self.config.urlconfs:
|
70
74
|
self.callbacks.update(_get_ninja_callbacks(self.config.urlconfs))
|
75
|
+
if self.config.include_django_views:
|
76
|
+
self.callbacks.update(_get_django_callbacks(self.config.urlconfs))
|
77
|
+
self.include_django_views = True
|
71
78
|
|
72
79
|
self.client = ApitallyClient(
|
73
80
|
client_id=self.config.client_id,
|
@@ -98,6 +105,7 @@ class ApitallyMiddleware:
|
|
98
105
|
request_logging_config: Optional[RequestLoggingConfig] = None,
|
99
106
|
app_version: Optional[str] = None,
|
100
107
|
identify_consumer_callback: Optional[str] = None,
|
108
|
+
include_django_views: bool = False,
|
101
109
|
urlconf: Optional[Union[List[Optional[str]], str]] = None,
|
102
110
|
proxy: Optional[str] = None,
|
103
111
|
) -> None:
|
@@ -109,6 +117,7 @@ class ApitallyMiddleware:
|
|
109
117
|
identify_consumer_callback=import_string(identify_consumer_callback)
|
110
118
|
if identify_consumer_callback
|
111
119
|
else None,
|
120
|
+
include_django_views=include_django_views,
|
112
121
|
urlconfs=[urlconf] if urlconf is None or isinstance(urlconf, str) else urlconf,
|
113
122
|
proxy=proxy,
|
114
123
|
)
|
@@ -116,6 +125,7 @@ class ApitallyMiddleware:
|
|
116
125
|
def __call__(self, request: HttpRequest) -> HttpResponse:
|
117
126
|
if self.client.enabled and request.method is not None and request.method != "OPTIONS":
|
118
127
|
timestamp = time.time()
|
128
|
+
|
119
129
|
request_size = parse_int(request.headers.get("Content-Length"))
|
120
130
|
request_body = b""
|
121
131
|
if self.capture_request_body:
|
@@ -128,32 +138,35 @@ class ApitallyMiddleware:
|
|
128
138
|
start_time = time.perf_counter()
|
129
139
|
response = self.get_response(request)
|
130
140
|
response_time = time.perf_counter() - start_time
|
131
|
-
response_size = (
|
132
|
-
parse_int(response["Content-Length"])
|
133
|
-
if response.has_header("Content-Length")
|
134
|
-
else (len(response.content) if not response.streaming else None)
|
135
|
-
)
|
136
|
-
response_body = b""
|
137
|
-
response_content_type = response.get("Content-Type")
|
138
|
-
if (
|
139
|
-
self.capture_response_body
|
140
|
-
and not response.streaming
|
141
|
-
and RequestLogger.is_supported_content_type(response_content_type)
|
142
|
-
):
|
143
|
-
response_body = (
|
144
|
-
response.content if response_size is not None and response_size <= MAX_BODY_SIZE else BODY_TOO_LARGE
|
145
|
-
)
|
146
|
-
|
147
|
-
try:
|
148
|
-
consumer = self.get_consumer(request)
|
149
|
-
consumer_identifier = consumer.identifier if consumer else None
|
150
|
-
self.client.consumer_registry.add_or_update_consumer(consumer)
|
151
|
-
except Exception: # pragma: no cover
|
152
|
-
logger.exception("Failed to get consumer for request")
|
153
|
-
consumer_identifier = None
|
154
|
-
|
155
141
|
path = self.get_path(request)
|
142
|
+
|
156
143
|
if path is not None:
|
144
|
+
try:
|
145
|
+
consumer = self.get_consumer(request)
|
146
|
+
consumer_identifier = consumer.identifier if consumer else None
|
147
|
+
self.client.consumer_registry.add_or_update_consumer(consumer)
|
148
|
+
except Exception: # pragma: no cover
|
149
|
+
logger.exception("Failed to get consumer for request")
|
150
|
+
consumer_identifier = None
|
151
|
+
|
152
|
+
response_size = (
|
153
|
+
parse_int(response["Content-Length"])
|
154
|
+
if response.has_header("Content-Length")
|
155
|
+
else (len(response.content) if not response.streaming else None)
|
156
|
+
)
|
157
|
+
response_body = b""
|
158
|
+
response_content_type = response.get("Content-Type")
|
159
|
+
if (
|
160
|
+
self.capture_response_body
|
161
|
+
and not response.streaming
|
162
|
+
and RequestLogger.is_supported_content_type(response_content_type)
|
163
|
+
):
|
164
|
+
response_body = (
|
165
|
+
response.content
|
166
|
+
if response_size is not None and response_size <= MAX_BODY_SIZE
|
167
|
+
else BODY_TOO_LARGE
|
168
|
+
)
|
169
|
+
|
157
170
|
try:
|
158
171
|
self.client.request_counter.add_request(
|
159
172
|
consumer=consumer_identifier,
|
@@ -196,27 +209,27 @@ class ApitallyMiddleware:
|
|
196
209
|
except Exception: # pragma: no cover
|
197
210
|
logger.exception("Failed to log server error")
|
198
211
|
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
212
|
+
if self.client.request_logger.enabled:
|
213
|
+
self.client.request_logger.log_request(
|
214
|
+
request={
|
215
|
+
"timestamp": timestamp,
|
216
|
+
"method": request.method,
|
217
|
+
"path": path,
|
218
|
+
"url": request.build_absolute_uri(),
|
219
|
+
"headers": list(request.headers.items()),
|
220
|
+
"size": request_size,
|
221
|
+
"consumer": consumer_identifier,
|
222
|
+
"body": request_body,
|
223
|
+
},
|
224
|
+
response={
|
225
|
+
"status_code": response.status_code,
|
226
|
+
"response_time": response_time,
|
227
|
+
"headers": list(response.items()),
|
228
|
+
"size": response_size,
|
229
|
+
"body": response_body,
|
230
|
+
},
|
231
|
+
exception=getattr(request, "unhandled_exception", None),
|
232
|
+
)
|
220
233
|
else:
|
221
234
|
response = self.get_response(request)
|
222
235
|
|
@@ -240,8 +253,9 @@ class ApitallyMiddleware:
|
|
240
253
|
from ninja.operation import PathView
|
241
254
|
|
242
255
|
if hasattr(match.func, "__self__") and isinstance(match.func.__self__, PathView):
|
243
|
-
|
244
|
-
|
256
|
+
return _transform_path(match.route)
|
257
|
+
if self.include_django_views:
|
258
|
+
return _transform_path(match.route)
|
245
259
|
except Exception: # pragma: no cover
|
246
260
|
logger.exception("Failed to get path for request")
|
247
261
|
return None
|
@@ -263,10 +277,12 @@ class ApitallyMiddleware:
|
|
263
277
|
return None
|
264
278
|
|
265
279
|
|
266
|
-
def _get_startup_data(
|
280
|
+
def _get_startup_data(
|
281
|
+
app_version: Optional[str], urlconfs: List[Optional[str]], include_django_views: bool = False
|
282
|
+
) -> Dict[str, Any]:
|
267
283
|
data: Dict[str, Any] = {}
|
268
284
|
try:
|
269
|
-
data["paths"] = _get_paths(urlconfs)
|
285
|
+
data["paths"] = _get_paths(urlconfs, include_django_views=include_django_views)
|
270
286
|
except Exception: # pragma: no cover
|
271
287
|
data["paths"] = []
|
272
288
|
logger.exception("Failed to get paths")
|
@@ -293,13 +309,26 @@ def _get_openapi(urlconfs: List[Optional[str]]) -> Optional[str]:
|
|
293
309
|
return None # pragma: no cover
|
294
310
|
|
295
311
|
|
296
|
-
def _get_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
|
312
|
+
def _get_paths(urlconfs: List[Optional[str]], include_django_views: bool = False) -> List[Dict[str, str]]:
|
297
313
|
paths = []
|
298
314
|
with contextlib.suppress(ImportError):
|
299
315
|
paths.extend(_get_drf_paths(urlconfs))
|
300
316
|
with contextlib.suppress(ImportError):
|
301
317
|
paths.extend(_get_ninja_paths(urlconfs))
|
302
|
-
|
318
|
+
if include_django_views:
|
319
|
+
paths.extend(_get_django_paths(urlconfs))
|
320
|
+
return _deduplicate_paths(paths)
|
321
|
+
|
322
|
+
|
323
|
+
def _deduplicate_paths(paths: List[Dict[str, str]]) -> List[Dict[str, str]]:
|
324
|
+
seen = set()
|
325
|
+
deduplicated_paths = []
|
326
|
+
for path in paths:
|
327
|
+
key = (path["method"], path["path"])
|
328
|
+
if key not in seen:
|
329
|
+
seen.add(key)
|
330
|
+
deduplicated_paths.append(path)
|
331
|
+
return deduplicated_paths
|
303
332
|
|
304
333
|
|
305
334
|
def _get_drf_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
|
@@ -405,6 +434,37 @@ def _get_ninja_api_instances(
|
|
405
434
|
return apis
|
406
435
|
|
407
436
|
|
437
|
+
def _get_django_paths(urlconfs: Optional[List[Optional[str]]] = None) -> List[Dict[str, str]]:
|
438
|
+
if urlconfs is None:
|
439
|
+
urlconfs = [None]
|
440
|
+
return [
|
441
|
+
{
|
442
|
+
"method": method.upper(),
|
443
|
+
"path": _transform_path(regex),
|
444
|
+
}
|
445
|
+
for urlconf in urlconfs
|
446
|
+
for callback, regex, _, _ in extract_views_from_urlpatterns(get_resolver(urlconf).url_patterns)
|
447
|
+
if hasattr(callback, "view_class") and issubclass(callback.view_class, View)
|
448
|
+
for method in callback.view_class.http_method_names
|
449
|
+
if method != "options" and hasattr(callback.view_class, method)
|
450
|
+
]
|
451
|
+
|
452
|
+
|
453
|
+
def _get_django_callbacks(urlconfs: Optional[List[Optional[str]]] = None) -> Set[Callable]:
|
454
|
+
if urlconfs is None:
|
455
|
+
urlconfs = [None]
|
456
|
+
return {
|
457
|
+
callback
|
458
|
+
for urlconf in urlconfs
|
459
|
+
for callback, _, _, _ in extract_views_from_urlpatterns(get_resolver(urlconf).url_patterns)
|
460
|
+
}
|
461
|
+
|
462
|
+
|
463
|
+
def _transform_path(path: str) -> str:
|
464
|
+
path = simplify_regex(path)
|
465
|
+
return re.sub(r"<(?:(?P<converter>[^>:]+):)?(?P<parameter>\w+)>", r"{\g<parameter>}", path)
|
466
|
+
|
467
|
+
|
408
468
|
def _check_import(name: str) -> bool:
|
409
469
|
try:
|
410
470
|
import_module(name)
|
apitally/starlette.py
CHANGED
@@ -1,16 +1,18 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import time
|
4
|
-
from
|
4
|
+
from contextlib import asynccontextmanager
|
5
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
|
5
6
|
from warnings import warn
|
6
7
|
|
7
8
|
from httpx import HTTPStatusError, Proxy
|
9
|
+
from starlette.applications import Starlette
|
8
10
|
from starlette.datastructures import Headers
|
9
11
|
from starlette.requests import Request
|
10
12
|
from starlette.routing import BaseRoute, Match, Router
|
11
13
|
from starlette.schemas import EndpointInfo, SchemaGenerator
|
12
14
|
from starlette.testclient import TestClient
|
13
|
-
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
15
|
+
from starlette.types import ASGIApp, Lifespan, Message, Receive, Scope, Send
|
14
16
|
|
15
17
|
from apitally.client.client_asyncio import ApitallyClient
|
16
18
|
from apitally.client.consumers import Consumer as ApitallyConsumer
|
@@ -56,10 +58,13 @@ class ApitallyMiddleware:
|
|
56
58
|
self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
|
57
59
|
)
|
58
60
|
|
59
|
-
|
60
|
-
|
61
|
+
_inject_lifespan_handlers(
|
62
|
+
app,
|
63
|
+
on_startup=self.on_startup,
|
64
|
+
on_shutdown=self.client.handle_shutdown,
|
65
|
+
)
|
61
66
|
|
62
|
-
def on_startup(self) -> None:
|
67
|
+
async def on_startup(self) -> None:
|
63
68
|
data = _get_startup_data(self.app, app_version=self.app_version, openapi_url=self.openapi_url)
|
64
69
|
self.client.set_startup_data(data)
|
65
70
|
self.client.start_sync_loop()
|
@@ -289,8 +294,30 @@ def _get_routes(app: Union[ASGIApp, Router]) -> List[BaseRoute]:
|
|
289
294
|
return [] # pragma: no cover
|
290
295
|
|
291
296
|
|
292
|
-
def
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
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
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: apitally
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.17.0
|
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">
|
@@ -1,14 +1,14 @@
|
|
1
1
|
apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
|
2
2
|
apitally/blacksheep.py,sha256=KvcPFeiwQgWZmRglbm8SLaN6_WRs5kZ3SymB1IuLR-A,9616
|
3
3
|
apitally/common.py,sha256=azDxepViH0QW0MuufTHxeSQyLGzCkocAX_KPziWTx8A,1605
|
4
|
-
apitally/django.py,sha256=
|
4
|
+
apitally/django.py,sha256=MnyL6ntMYj1WndjLqpDZ-8BrbDWCeVV2dpZbe8Fnm-Y,19254
|
5
5
|
apitally/django_ninja.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
|
6
6
|
apitally/django_rest_framework.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
|
7
7
|
apitally/fastapi.py,sha256=IfKfgsmIY8_AtnuMTW2sW4qnkya61CAE2vBoIpcc9tk,169
|
8
8
|
apitally/flask.py,sha256=OoCEnjtnD51GUGq-adK80ebuiLj-5HXubxffCv5XTCM,9622
|
9
9
|
apitally/litestar.py,sha256=mHoMqBO_gyoopeHljY8e8GTcV29UDf3uhQMxY3GeNpA,13451
|
10
10
|
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
11
|
-
apitally/starlette.py,sha256=
|
11
|
+
apitally/starlette.py,sha256=081vXTNOy6-zZ8ugXknRtQZnFFqG7XKLW4Ho6oaHEUY,13525
|
12
12
|
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
13
|
apitally/client/client_asyncio.py,sha256=rTsH5wlLHK3RmyIuEiT6vzjquU-l2OPC34JnC2U6uYw,6658
|
14
14
|
apitally/client/client_base.py,sha256=DvivGeHd3dyOASRvkIo44Zh8RzdBMfH8_rROa2lFbgw,3799
|
@@ -20,7 +20,7 @@ apitally/client/requests.py,sha256=SDptGOg9XvaEKFj2o3oxJz-JAuZzUrqpHnbOQixf99o,3
|
|
20
20
|
apitally/client/sentry.py,sha256=qMjHdI0V7c50ruo1WjmjWc8g6oGDv724vSCvcuZ8G9k,1188
|
21
21
|
apitally/client/server_errors.py,sha256=4B2BKDFoIpoWc55UVH6AIdYSgzj6zxCdMNUW77JjhZw,3423
|
22
22
|
apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
|
23
|
-
apitally-0.
|
24
|
-
apitally-0.
|
25
|
-
apitally-0.
|
26
|
-
apitally-0.
|
23
|
+
apitally-0.17.0.dist-info/METADATA,sha256=ERuU63GwG_N8quZ137RNkkTjfF0r3hAzoM514YjKfvs,9321
|
24
|
+
apitally-0.17.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
25
|
+
apitally-0.17.0.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
26
|
+
apitally-0.17.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|