apitally 0.5.0__py3-none-any.whl → 0.6.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 +1 -1
- apitally/client/asyncio.py +1 -1
- apitally/client/base.py +5 -4
- apitally/client/threading.py +1 -1
- apitally/litestar.py +186 -0
- {apitally-0.5.0.dist-info → apitally-0.6.0.dist-info}/METADATA +28 -3
- {apitally-0.5.0.dist-info → apitally-0.6.0.dist-info}/RECORD +9 -8
- {apitally-0.5.0.dist-info → apitally-0.6.0.dist-info}/LICENSE +0 -0
- {apitally-0.5.0.dist-info → apitally-0.6.0.dist-info}/WHEEL +0 -0
apitally/__init__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = "0.
|
1
|
+
__version__ = "0.6.0"
|
apitally/client/asyncio.py
CHANGED
@@ -98,7 +98,7 @@ class ApitallyClient(ApitallyClientBase):
|
|
98
98
|
async def _send_app_info(self, client: httpx.AsyncClient, payload: Dict[str, Any]) -> None:
|
99
99
|
logger.debug("Sending app info")
|
100
100
|
response = await client.post(url="/info", json=payload, timeout=REQUEST_TIMEOUT)
|
101
|
-
if response.status_code == 404
|
101
|
+
if response.status_code == 404:
|
102
102
|
self.stop_sync_loop()
|
103
103
|
logger.error(f"Invalid Apitally client ID {self.client_id}")
|
104
104
|
else:
|
apitally/client/base.py
CHANGED
@@ -4,6 +4,7 @@ import os
|
|
4
4
|
import re
|
5
5
|
import threading
|
6
6
|
import time
|
7
|
+
from abc import ABC
|
7
8
|
from collections import Counter
|
8
9
|
from dataclasses import dataclass
|
9
10
|
from math import floor
|
@@ -26,16 +27,16 @@ INITIAL_SYNC_INTERVAL_DURATION = 3600
|
|
26
27
|
TApitallyClient = TypeVar("TApitallyClient", bound="ApitallyClientBase")
|
27
28
|
|
28
29
|
|
29
|
-
class ApitallyClientBase:
|
30
|
+
class ApitallyClientBase(ABC):
|
30
31
|
_instance: Optional[ApitallyClientBase] = None
|
31
32
|
_lock = threading.Lock()
|
32
33
|
|
33
|
-
def __new__(cls, *args, **kwargs) ->
|
34
|
+
def __new__(cls: Type[TApitallyClient], *args, **kwargs) -> TApitallyClient:
|
34
35
|
if cls._instance is None:
|
35
36
|
with cls._lock:
|
36
37
|
if cls._instance is None:
|
37
38
|
cls._instance = super().__new__(cls)
|
38
|
-
return cls._instance
|
39
|
+
return cast(TApitallyClient, cls._instance)
|
39
40
|
|
40
41
|
def __init__(self, client_id: str, env: str) -> None:
|
41
42
|
if hasattr(self, "client_id"):
|
@@ -194,8 +195,8 @@ class ValidationErrorCounter:
|
|
194
195
|
method=method.upper(),
|
195
196
|
path=path,
|
196
197
|
loc=tuple(str(loc) for loc in error["loc"]),
|
197
|
-
type=error["type"],
|
198
198
|
msg=error["msg"],
|
199
|
+
type=error["type"],
|
199
200
|
)
|
200
201
|
self.error_counts[validation_error] += 1
|
201
202
|
except (KeyError, TypeError): # pragma: no cover
|
apitally/client/threading.py
CHANGED
@@ -112,7 +112,7 @@ class ApitallyClient(ApitallyClientBase):
|
|
112
112
|
def _send_app_info(self, session: requests.Session, payload: Dict[str, Any]) -> None:
|
113
113
|
logger.debug("Sending app info")
|
114
114
|
response = session.post(url=f"{self.hub_url}/info", json=payload, timeout=REQUEST_TIMEOUT)
|
115
|
-
if response.status_code == 404
|
115
|
+
if response.status_code == 404:
|
116
116
|
self.stop_sync_loop()
|
117
117
|
logger.error(f"Invalid Apitally client ID {self.client_id}")
|
118
118
|
else:
|
apitally/litestar.py
ADDED
@@ -0,0 +1,186 @@
|
|
1
|
+
import contextlib
|
2
|
+
import json
|
3
|
+
import sys
|
4
|
+
import time
|
5
|
+
from importlib.metadata import version
|
6
|
+
from typing import Callable, Dict, List, Optional
|
7
|
+
|
8
|
+
from litestar.app import DEFAULT_OPENAPI_CONFIG, Litestar
|
9
|
+
from litestar.config.app import AppConfig
|
10
|
+
from litestar.connection import Request
|
11
|
+
from litestar.datastructures import Headers
|
12
|
+
from litestar.enums import ScopeType
|
13
|
+
from litestar.handlers import HTTPRouteHandler
|
14
|
+
from litestar.plugins import InitPluginProtocol
|
15
|
+
from litestar.types import ASGIApp, Message, Receive, Scope, Send
|
16
|
+
|
17
|
+
from apitally.client.asyncio import ApitallyClient
|
18
|
+
|
19
|
+
|
20
|
+
__all__ = ["ApitallyPlugin"]
|
21
|
+
|
22
|
+
|
23
|
+
class ApitallyPlugin(InitPluginProtocol):
|
24
|
+
def __init__(
|
25
|
+
self,
|
26
|
+
client_id: str,
|
27
|
+
env: str = "dev",
|
28
|
+
app_version: Optional[str] = None,
|
29
|
+
filter_openapi_paths: bool = True,
|
30
|
+
identify_consumer_callback: Optional[Callable[[Request], Optional[str]]] = None,
|
31
|
+
) -> None:
|
32
|
+
self.client = ApitallyClient(client_id=client_id, env=env)
|
33
|
+
self.app_version = app_version
|
34
|
+
self.filter_openapi_paths = filter_openapi_paths
|
35
|
+
self.identify_consumer_callback = identify_consumer_callback
|
36
|
+
self.openapi_path: Optional[str] = None
|
37
|
+
|
38
|
+
def on_app_init(self, app_config: AppConfig) -> AppConfig:
|
39
|
+
app_config.on_startup.append(self.on_startup)
|
40
|
+
app_config.middleware.append(self.middleware_factory)
|
41
|
+
return app_config
|
42
|
+
|
43
|
+
def on_startup(self, app: Litestar) -> None:
|
44
|
+
openapi_config = app.openapi_config or DEFAULT_OPENAPI_CONFIG
|
45
|
+
self.openapi_path = openapi_config.openapi_controller.path
|
46
|
+
|
47
|
+
app_info = {
|
48
|
+
"openapi": _get_openapi(app),
|
49
|
+
"paths": [route for route in _get_routes(app) if not self.filter_path(route["path"])],
|
50
|
+
"versions": _get_versions(self.app_version),
|
51
|
+
"client": "python:litestar",
|
52
|
+
}
|
53
|
+
self.client.set_app_info(app_info)
|
54
|
+
self.client.start_sync_loop()
|
55
|
+
|
56
|
+
def middleware_factory(self, app: ASGIApp) -> ASGIApp:
|
57
|
+
async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
|
58
|
+
if scope["type"] == "http" and scope["method"] != "OPTIONS":
|
59
|
+
request = Request(scope)
|
60
|
+
response_status = 0
|
61
|
+
response_time = 0.0
|
62
|
+
response_headers = Headers()
|
63
|
+
response_body = b""
|
64
|
+
start_time = time.perf_counter()
|
65
|
+
|
66
|
+
async def send_wrapper(message: Message) -> None:
|
67
|
+
nonlocal response_time, response_status, response_headers, response_body
|
68
|
+
if message["type"] == "http.response.start":
|
69
|
+
response_time = time.perf_counter() - start_time
|
70
|
+
response_status = message["status"]
|
71
|
+
response_headers = Headers(message["headers"])
|
72
|
+
elif message["type"] == "http.response.body" and response_status == 400:
|
73
|
+
response_body += message["body"]
|
74
|
+
await send(message)
|
75
|
+
|
76
|
+
await app(scope, receive, send_wrapper)
|
77
|
+
self.add_request(
|
78
|
+
request=request,
|
79
|
+
response_status=response_status,
|
80
|
+
response_time=response_time,
|
81
|
+
response_headers=response_headers,
|
82
|
+
response_body=response_body,
|
83
|
+
)
|
84
|
+
else:
|
85
|
+
await app(scope, receive, send) # pragma: no cover
|
86
|
+
|
87
|
+
return middleware
|
88
|
+
|
89
|
+
def add_request(
|
90
|
+
self,
|
91
|
+
request: Request,
|
92
|
+
response_status: int,
|
93
|
+
response_time: float,
|
94
|
+
response_headers: Headers,
|
95
|
+
response_body: bytes,
|
96
|
+
) -> None:
|
97
|
+
if response_status < 100 or not request.route_handler.paths:
|
98
|
+
return # pragma: no cover
|
99
|
+
path = self.get_path(request)
|
100
|
+
if path is None or self.filter_path(path):
|
101
|
+
return
|
102
|
+
consumer = self.get_consumer(request)
|
103
|
+
self.client.request_counter.add_request(
|
104
|
+
consumer=consumer,
|
105
|
+
method=request.method,
|
106
|
+
path=path,
|
107
|
+
status_code=response_status,
|
108
|
+
response_time=response_time,
|
109
|
+
request_size=request.headers.get("Content-Length"),
|
110
|
+
response_size=response_headers.get("Content-Length"),
|
111
|
+
)
|
112
|
+
if response_status == 400 and response_body and len(response_body) < 4096:
|
113
|
+
with contextlib.suppress(json.JSONDecodeError):
|
114
|
+
parsed_body = json.loads(response_body)
|
115
|
+
if (
|
116
|
+
isinstance(parsed_body, dict)
|
117
|
+
and "detail" in parsed_body
|
118
|
+
and isinstance(parsed_body["detail"], str)
|
119
|
+
and "validation" in parsed_body["detail"].lower()
|
120
|
+
and "extra" in parsed_body
|
121
|
+
and isinstance(parsed_body["extra"], list)
|
122
|
+
):
|
123
|
+
self.client.validation_error_counter.add_validation_errors(
|
124
|
+
consumer=consumer,
|
125
|
+
method=request.method,
|
126
|
+
path=path,
|
127
|
+
detail=[
|
128
|
+
{
|
129
|
+
"loc": [error.get("source", "body")] + error["key"].split("."),
|
130
|
+
"msg": error["message"],
|
131
|
+
"type": "",
|
132
|
+
}
|
133
|
+
for error in parsed_body["extra"]
|
134
|
+
if "key" in error and "message" in error
|
135
|
+
],
|
136
|
+
)
|
137
|
+
|
138
|
+
def get_path(self, request: Request) -> Optional[str]:
|
139
|
+
path: List[str] = []
|
140
|
+
for layer in request.route_handler.ownership_layers:
|
141
|
+
if isinstance(layer, HTTPRouteHandler):
|
142
|
+
if len(layer.paths) == 0:
|
143
|
+
return None # pragma: no cover
|
144
|
+
path.append(list(layer.paths)[0].lstrip("/"))
|
145
|
+
else:
|
146
|
+
path.append(layer.path.lstrip("/"))
|
147
|
+
return "/" + "/".join(filter(None, path))
|
148
|
+
|
149
|
+
def filter_path(self, path: str) -> bool:
|
150
|
+
if self.filter_openapi_paths and self.openapi_path:
|
151
|
+
return path == self.openapi_path or path.startswith(self.openapi_path + "/")
|
152
|
+
return False # pragma: no cover
|
153
|
+
|
154
|
+
def get_consumer(self, request: Request) -> Optional[str]:
|
155
|
+
if hasattr(request.state, "consumer_identifier"):
|
156
|
+
return str(request.state.consumer_identifier)
|
157
|
+
if self.identify_consumer_callback is not None:
|
158
|
+
consumer_identifier = self.identify_consumer_callback(request)
|
159
|
+
if consumer_identifier is not None:
|
160
|
+
return str(consumer_identifier)
|
161
|
+
return None
|
162
|
+
|
163
|
+
|
164
|
+
def _get_openapi(app: Litestar) -> str:
|
165
|
+
schema = app.openapi_schema.to_schema()
|
166
|
+
return json.dumps(schema)
|
167
|
+
|
168
|
+
|
169
|
+
def _get_routes(app: Litestar) -> List[Dict[str, str]]:
|
170
|
+
return [
|
171
|
+
{"method": method, "path": route.path}
|
172
|
+
for route in app.routes
|
173
|
+
for method in route.methods
|
174
|
+
if route.scope_type == ScopeType.HTTP and method != "OPTIONS"
|
175
|
+
]
|
176
|
+
|
177
|
+
|
178
|
+
def _get_versions(app_version: Optional[str]) -> Dict[str, str]:
|
179
|
+
versions = {
|
180
|
+
"python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
181
|
+
"apitally": version("apitally"),
|
182
|
+
"litestar": version("litestar"),
|
183
|
+
}
|
184
|
+
if app_version:
|
185
|
+
versions["app"] = app_version
|
186
|
+
return versions
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: apitally
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.6.0
|
4
4
|
Summary: API monitoring for REST APIs built with FastAPI, Flask, Django, and Starlette.
|
5
5
|
Home-page: https://apitally.io
|
6
6
|
License: MIT
|
@@ -26,6 +26,7 @@ Provides-Extra: django-ninja
|
|
26
26
|
Provides-Extra: django-rest-framework
|
27
27
|
Provides-Extra: fastapi
|
28
28
|
Provides-Extra: flask
|
29
|
+
Provides-Extra: litestar
|
29
30
|
Provides-Extra: starlette
|
30
31
|
Requires-Dist: backoff (>=2.0.0)
|
31
32
|
Requires-Dist: django (>=4.0) ; extra == "django" or extra == "django-ninja" or extra == "django-rest-framework"
|
@@ -33,7 +34,8 @@ Requires-Dist: django-ninja (>=0.18.0) ; extra == "django-ninja"
|
|
33
34
|
Requires-Dist: djangorestframework (>=3.12.0) ; extra == "django-rest-framework"
|
34
35
|
Requires-Dist: fastapi (>=0.87.0) ; extra == "fastapi"
|
35
36
|
Requires-Dist: flask (>=2.0.0) ; extra == "flask"
|
36
|
-
Requires-Dist: httpx (>=0.22.0) ; extra == "fastapi" or extra == "starlette"
|
37
|
+
Requires-Dist: httpx (>=0.22.0) ; extra == "fastapi" or extra == "litestar" or extra == "starlette"
|
38
|
+
Requires-Dist: litestar (>=2.0.0) ; extra == "litestar"
|
37
39
|
Requires-Dist: requests (>=2.26.0) ; extra == "django" or extra == "django-ninja" or extra == "django-rest-framework" or extra == "flask"
|
38
40
|
Requires-Dist: starlette (>=0.21.0,<1.0.0) ; extra == "fastapi" or extra == "starlette"
|
39
41
|
Project-URL: Documentation, https://docs.apitally.io
|
@@ -72,6 +74,7 @@ frameworks:
|
|
72
74
|
- [Flask](https://docs.apitally.io/frameworks/flask)
|
73
75
|
- [Django Ninja](https://docs.apitally.io/frameworks/django-ninja)
|
74
76
|
- [Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework)
|
77
|
+
- [Litestar](https://docs.apitally.io/frameworks/litestar)
|
75
78
|
|
76
79
|
Learn more about Apitally on our 🌎 [website](https://apitally.io) or check out
|
77
80
|
the 📚 [documentation](https://docs.apitally.io).
|
@@ -92,7 +95,8 @@ example:
|
|
92
95
|
pip install apitally[fastapi]
|
93
96
|
```
|
94
97
|
|
95
|
-
The available extras are: `fastapi`, `starlette`, `flask` and
|
98
|
+
The available extras are: `fastapi`, `starlette`, `flask`, `django` and
|
99
|
+
`litestar`.
|
96
100
|
|
97
101
|
## Usage
|
98
102
|
|
@@ -154,6 +158,27 @@ APITALLY_MIDDLEWARE = {
|
|
154
158
|
}
|
155
159
|
```
|
156
160
|
|
161
|
+
### Litestar
|
162
|
+
|
163
|
+
This is an example of how to add the Apitally plugin to a Litestar application.
|
164
|
+
For further instructions, see our
|
165
|
+
[setup guide for Litestar](https://docs.apitally.io/frameworks/litestar).
|
166
|
+
|
167
|
+
```python
|
168
|
+
from litestar import Litestar
|
169
|
+
from apitally.litestar import ApitallyPlugin
|
170
|
+
|
171
|
+
app = Litestar(
|
172
|
+
route_handlers=[...],
|
173
|
+
plugins=[
|
174
|
+
ApitallyPlugin(
|
175
|
+
client_id="your-client-id",
|
176
|
+
env="dev", # or "prod" etc.
|
177
|
+
),
|
178
|
+
]
|
179
|
+
)
|
180
|
+
```
|
181
|
+
|
157
182
|
## Getting help
|
158
183
|
|
159
184
|
If you need help please
|
@@ -1,17 +1,18 @@
|
|
1
|
-
apitally/__init__.py,sha256=
|
1
|
+
apitally/__init__.py,sha256=cID1jLnC_vj48GgMN6Yb1FA3JsQ95zNmCHmRYE8TFhY,22
|
2
2
|
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
-
apitally/client/asyncio.py,sha256=
|
4
|
-
apitally/client/base.py,sha256=
|
3
|
+
apitally/client/asyncio.py,sha256=uR5JlH37G6gZvAJ7A1gYOGkjn3zjC-4I6avA1fncXHs,4433
|
4
|
+
apitally/client/base.py,sha256=g-v7_S-ie2DN2HCVtgTlImZGTMXVU9CrKraMZiyM6Ds,8353
|
5
5
|
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
6
|
-
apitally/client/threading.py,sha256=
|
6
|
+
apitally/client/threading.py,sha256=ihQzUStrSQFynpqXgFpseAXrHuc5Et1QvG-YHlzqDr8,4831
|
7
7
|
apitally/django.py,sha256=vL2vBelXis-9d3CDWjKzLsOjdSbBDnZX0v1oDtBvtNM,9943
|
8
8
|
apitally/django_ninja.py,sha256=iMvZd7j04nbOLpJgYxs7tpbsyXlZuhmHjcswXMvyUlU,82
|
9
9
|
apitally/django_rest_framework.py,sha256=iMvZd7j04nbOLpJgYxs7tpbsyXlZuhmHjcswXMvyUlU,82
|
10
10
|
apitally/fastapi.py,sha256=Q3n2bVREKQ_V_2yCQ48ngPtr-NJxDskpT_l20xhSbpM,85
|
11
11
|
apitally/flask.py,sha256=-R0MP72ufO3v0p30JBU9asODWtuOU3FOCl5iY-kTSzw,5099
|
12
|
+
apitally/litestar.py,sha256=VgKhbKRjVHVjzdw7PgMxedaOrSB6_ZUo9L4XNr3iRxc,7437
|
12
13
|
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
14
|
apitally/starlette.py,sha256=VlLWOR9rzNlI9IjFbQ86APaWanURerIt52tX6bRGzCo,7814
|
14
|
-
apitally-0.
|
15
|
-
apitally-0.
|
16
|
-
apitally-0.
|
17
|
-
apitally-0.
|
15
|
+
apitally-0.6.0.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
16
|
+
apitally-0.6.0.dist-info/METADATA,sha256=CbNI6H1gnNT3oPXzM_RxWFksCtwZ7_uT7lunjarMKXA,6610
|
17
|
+
apitally-0.6.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
|
18
|
+
apitally-0.6.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|