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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.5.0"
1
+ __version__ = "0.6.0"
@@ -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 and "Client ID" in response.text:
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) -> ApitallyClientBase:
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
@@ -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 and "Client ID" in response.text:
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.5.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 `django`.
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=LBK46heutvn3KmsCrKIYu8RQikbfnjZaj2xFrXaeCzQ,22
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=YfvJlL9YtZY6dv7fXTe2yKd3lJRC6yoBThdP_JA0PZE,4466
4
- apitally/client/base.py,sha256=AbDxwxGFwoY19-OvIHG5Haz3Np0MqNympATS1xdAvzU,8285
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=iMLJJ2-5hiF_dWeVTIW4iKMDQg0wqAmTwDha7A_hPnM,4864
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.5.0.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
15
- apitally-0.5.0.dist-info/METADATA,sha256=ESe-v-Uxtf4y60DBp6VR0hWies8x_bNQtRkVTxSkuUM,5961
16
- apitally-0.5.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
17
- apitally-0.5.0.dist-info/RECORD,,
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,,