apitally 0.11.3__py3-none-any.whl → 0.12.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/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.11.3"
1
+ __version__ = "0.12.0"
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 TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union
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.concurrency import iterate_in_threadpool
11
- from starlette.middleware.base import BaseHTTPMiddleware
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(BaseHTTPMiddleware):
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,59 @@ 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 dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
65
- try:
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""
66
65
  start_time = time.perf_counter()
67
- response = await call_next(request)
68
- except BaseException as e:
69
- await self.add_request(
70
- request=request,
71
- response=None,
72
- status_code=HTTP_500_INTERNAL_SERVER_ERROR,
73
- response_time=time.perf_counter() - start_time,
74
- exception=e,
75
- )
76
- raise e from None
66
+
67
+ async def send_wrapper(message: Message) -> None:
68
+ nonlocal response_time, response_status, response_headers, response_body
69
+ if message["type"] == "http.response.start":
70
+ response_time = time.perf_counter() - start_time
71
+ response_status = message["status"]
72
+ response_headers = Headers(scope=message)
73
+ elif message["type"] == "http.response.body" and response_status == 422:
74
+ response_body += message["body"]
75
+ await send(message)
76
+
77
+ try:
78
+ await self.app(scope, receive, send_wrapper)
79
+ except BaseException as e:
80
+ self.add_request(
81
+ request=request,
82
+ response_status=500,
83
+ response_time=time.perf_counter() - start_time,
84
+ response_headers=response_headers,
85
+ response_body=response_body,
86
+ exception=e,
87
+ )
88
+ raise e from None
89
+ else:
90
+ self.add_request(
91
+ request=request,
92
+ response_status=response_status,
93
+ response_time=response_time,
94
+ response_headers=response_headers,
95
+ response_body=response_body,
96
+ )
77
97
  else:
78
- await self.add_request(
79
- request=request,
80
- response=response,
81
- status_code=response.status_code,
82
- response_time=time.perf_counter() - start_time,
83
- )
84
- return response
98
+ await self.app(scope, receive, send) # pragma: no cover
85
99
 
86
- async def add_request(
100
+ def add_request(
87
101
  self,
88
102
  request: Request,
89
- response: Optional[Response],
90
- status_code: int,
103
+ response_status: int,
91
104
  response_time: float,
105
+ response_headers: Headers,
106
+ response_body: bytes,
92
107
  exception: Optional[BaseException] = None,
93
108
  ) -> None:
94
109
  path_template, is_handled_path = self.get_path_template(request)
95
- if (is_handled_path or not self.filter_unhandled_paths) and request.method != "OPTIONS":
110
+ if is_handled_path or not self.filter_unhandled_paths:
96
111
  consumer = self.get_consumer(request)
97
112
  consumer_identifier = consumer.identifier if consumer else None
98
113
  self.client.consumer_registry.add_or_update_consumer(consumer)
@@ -100,26 +115,23 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
100
115
  consumer=consumer_identifier,
101
116
  method=request.method,
102
117
  path=path_template,
103
- status_code=status_code,
118
+ status_code=response_status,
104
119
  response_time=response_time,
105
120
  request_size=request.headers.get("Content-Length"),
106
- response_size=response.headers.get("Content-Length") if response is not None else None,
121
+ response_size=response_headers.get("Content-Length"),
107
122
  )
108
- if (
109
- status_code == 422
110
- and response is not None
111
- and response.headers.get("Content-Type") == "application/json"
112
- ):
113
- body = await self.get_response_json(response)
114
- if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
115
- # Log FastAPI / Pydantic validation errors
116
- self.client.validation_error_counter.add_validation_errors(
117
- consumer=consumer_identifier,
118
- method=request.method,
119
- path=path_template,
120
- detail=body["detail"],
121
- )
122
- if status_code == 500 and exception is not None:
123
+ if response_status == 422 and response_body and response_headers.get("Content-Type") == "application/json":
124
+ with contextlib.suppress(json.JSONDecodeError):
125
+ body = json.loads(response_body)
126
+ if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
127
+ # Log FastAPI / Pydantic validation errors
128
+ self.client.validation_error_counter.add_validation_errors(
129
+ consumer=consumer_identifier,
130
+ method=request.method,
131
+ path=path_template,
132
+ detail=body["detail"],
133
+ )
134
+ if response_status == 500 and exception is not None:
123
135
  self.client.server_error_counter.add_server_error(
124
136
  consumer=consumer_identifier,
125
137
  method=request.method,
@@ -127,21 +139,6 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
127
139
  exception=exception,
128
140
  )
129
141
 
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
142
  @staticmethod
146
143
  def get_path_template(request: Request) -> Tuple[str, bool]:
147
144
  for route in request.app.routes:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: apitally
3
- Version: 0.11.3
3
+ Version: 0.12.0
4
4
  Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette and Litestar.
5
5
  Home-page: https://apitally.io
6
6
  License: MIT
@@ -1,4 +1,4 @@
1
- apitally/__init__.py,sha256=tCUnamQN48_YKI84dtjiVJI4cF4gypc8nKdvXAnhY_E,23
1
+ apitally/__init__.py,sha256=eHjt9DPsMbptabS2yGx9Yhbyzq5hFSUHXb7zc8Q_8-o,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
@@ -12,8 +12,8 @@ apitally/fastapi.py,sha256=hEyYZsvIaA3OXZSSFdey5iqeEjfBPHgfNbyX8pLm7GI,123
12
12
  apitally/flask.py,sha256=7TJIoAT91-bR_7gZkL0clDk-Whl-V21hbo4nASaDmB4,6447
13
13
  apitally/litestar.py,sha256=sQcrHw-JV9AlpnXlrczmaDe0k6tD9PYQsc8nyQul8Ko,8802
14
14
  apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- apitally/starlette.py,sha256=HvphuX401h_ccqvOk2RKu7GGEai2WbGOjJ-WOE7-fWM,8781
16
- apitally-0.11.3.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
17
- apitally-0.11.3.dist-info/METADATA,sha256=oIrHGgHHvCpqj7LzkhpvT3nbMiTChIkPK5pPGBwH8C8,6994
18
- apitally-0.11.3.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
19
- apitally-0.11.3.dist-info/RECORD,,
15
+ apitally/starlette.py,sha256=PA_0BTy9aVtrY2_QrWU7Js5vjW1uxZ476rmg95fXq2g,8881
16
+ apitally-0.12.0.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
17
+ apitally-0.12.0.dist-info/METADATA,sha256=gGryZ9u9sLV8mYrcoRhvYK4lQ4bFf9vhbvrCENA6T-I,6994
18
+ apitally-0.12.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
19
+ apitally-0.12.0.dist-info/RECORD,,