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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.11.3"
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 response_time, response_status, response_headers, response_body
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
- elif message["type"] == "http.response.body" and response_status == 400:
84
- response_body += message["body"]
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 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,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 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""
65
+ response_size = 0
66
+ response_chunked = False
66
67
  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
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.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
115
+ await self.app(scope, receive, send) # pragma: no cover
85
116
 
86
- async def add_request(
117
+ def add_request(
87
118
  self,
88
119
  request: Request,
89
- response: Optional[Response],
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 (is_handled_path or not self.filter_unhandled_paths) and request.method != "OPTIONS":
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=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=response.headers.get("Content-Length") if response is not None else None,
139
+ response_size=response_size or response_headers.get("Content-Length"),
107
140
  )
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:
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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: apitally
3
- Version: 0.11.3
3
+ Version: 0.12.1
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=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=sQcrHw-JV9AlpnXlrczmaDe0k6tD9PYQsc8nyQul8Ko,8802
13
+ apitally/litestar.py,sha256=O9bSzwJC-dN6ukRqyVNYBhUqxEpzie-bR2bFojcvvMI,9547
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=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,,