apitally 0.6.3__tar.gz → 0.7.1__tar.gz

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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: apitally
3
- Version: 0.6.3
4
- Summary: API monitoring for REST APIs built with FastAPI, Flask, Django, and Starlette.
3
+ Version: 0.7.1
4
+ Summary: API monitoring for REST APIs built with FastAPI, Flask, Django, Starlette and Litestar.
5
5
  Home-page: https://apitally.io
6
6
  License: MIT
7
7
  Author: Apitally
@@ -21,7 +21,6 @@ Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Topic :: Internet :: WWW/HTTP
22
22
  Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
23
23
  Classifier: Typing :: Typed
24
- Provides-Extra: django
25
24
  Provides-Extra: django-ninja
26
25
  Provides-Extra: django-rest-framework
27
26
  Provides-Extra: fastapi
@@ -29,15 +28,17 @@ Provides-Extra: flask
29
28
  Provides-Extra: litestar
30
29
  Provides-Extra: starlette
31
30
  Requires-Dist: backoff (>=2.0.0)
32
- Requires-Dist: django (>=2.2) ; extra == "django" or extra == "django-ninja" or extra == "django-rest-framework"
31
+ Requires-Dist: django (>=2.2) ; extra == "django-ninja" or extra == "django-rest-framework"
33
32
  Requires-Dist: django-ninja (>=0.18.0) ; extra == "django-ninja"
34
33
  Requires-Dist: djangorestframework (>=3.10.0) ; extra == "django-rest-framework"
35
34
  Requires-Dist: fastapi (>=0.87.0) ; extra == "fastapi"
36
35
  Requires-Dist: flask (>=2.0.0) ; extra == "flask"
37
36
  Requires-Dist: httpx (>=0.22.0) ; extra == "fastapi" or extra == "litestar" or extra == "starlette"
37
+ Requires-Dist: inflection (>=0.5.1) ; extra == "django-rest-framework"
38
38
  Requires-Dist: litestar (>=2.0.0) ; extra == "litestar"
39
- Requires-Dist: requests (>=2.26.0) ; extra == "django" or extra == "django-ninja" or extra == "django-rest-framework" or extra == "flask"
39
+ Requires-Dist: requests (>=2.26.0) ; extra == "django-ninja" or extra == "django-rest-framework" or extra == "flask"
40
40
  Requires-Dist: starlette (>=0.21.0,<1.0.0) ; extra == "fastapi" or extra == "starlette"
41
+ Requires-Dist: uritemplate (>=3.0.0) ; extra == "django-rest-framework"
41
42
  Project-URL: Documentation, https://docs.apitally.io
42
43
  Project-URL: Repository, https://github.com/apitally/python-client
43
44
  Description-Content-Type: text/markdown
@@ -52,7 +53,7 @@ Description-Content-Type: text/markdown
52
53
 
53
54
  <p align="center"><b>API monitoring made easy.</b></p>
54
55
 
55
- <p align="center"><i>Apitally is a simple and affordable API monitoring solution with a focus on data privacy. It is easy to set up and use for new and existing API projects using Python or Node.js.</i></p>
56
+ <p align="center"><i>Apitally is a simple and affordable API monitoring tool with a focus on data privacy.<br>It is super easy to use for API projects in Python or Node.js and never collects sensitive data.</i></p>
56
57
 
57
58
  <p align="center">🔗 <b><a href="https://apitally.io" target="_blank">apitally.io</a></b></p>
58
59
 
@@ -95,8 +96,8 @@ example:
95
96
  pip install apitally[fastapi]
96
97
  ```
97
98
 
98
- The available extras are: `fastapi`, `starlette`, `flask`, `django` and
99
- `litestar`.
99
+ The available extras are: `fastapi`, `flask`, `django_rest_framework`,
100
+ `django_ninja`, `starlette` and `litestar`.
100
101
 
101
102
  ## Usage
102
103
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  <p align="center"><b>API monitoring made easy.</b></p>
10
10
 
11
- <p align="center"><i>Apitally is a simple and affordable API monitoring solution with a focus on data privacy. It is easy to set up and use for new and existing API projects using Python or Node.js.</i></p>
11
+ <p align="center"><i>Apitally is a simple and affordable API monitoring tool with a focus on data privacy.<br>It is super easy to use for API projects in Python or Node.js and never collects sensitive data.</i></p>
12
12
 
13
13
  <p align="center">🔗 <b><a href="https://apitally.io" target="_blank">apitally.io</a></b></p>
14
14
 
@@ -51,8 +51,8 @@ example:
51
51
  pip install apitally[fastapi]
52
52
  ```
53
53
 
54
- The available extras are: `fastapi`, `starlette`, `flask`, `django` and
55
- `litestar`.
54
+ The available extras are: `fastapi`, `flask`, `django_rest_framework`,
55
+ `django_ninja`, `starlette` and `litestar`.
56
56
 
57
57
  ## Usage
58
58
 
@@ -0,0 +1 @@
1
+ __version__ = "0.7.1"
@@ -0,0 +1,31 @@
1
+ import sys
2
+ from importlib.metadata import PackageNotFoundError, version
3
+ from typing import Dict, Optional
4
+
5
+
6
+ def get_versions(*packages, app_version: Optional[str] = None) -> Dict[str, str]:
7
+ versions = _get_common_package_versions()
8
+ for package in packages:
9
+ versions[package] = _get_package_version(package)
10
+ if app_version:
11
+ versions["app"] = app_version
12
+ return {n: v for n, v in versions.items() if v is not None}
13
+
14
+
15
+ def _get_common_package_versions() -> Dict[str, Optional[str]]:
16
+ return {
17
+ "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
18
+ "apitally": _get_package_version("apitally"),
19
+ "uvicorn": _get_package_version("uvicorn"),
20
+ "hypercorn": _get_package_version("hypercorn"),
21
+ "daphne": _get_package_version("daphne"),
22
+ "gunicorn": _get_package_version("gunicorn"),
23
+ "uwsgi": _get_package_version("uwsgi"),
24
+ }
25
+
26
+
27
+ def _get_package_version(name: str) -> Optional[str]:
28
+ try:
29
+ return version(name)
30
+ except PackageNotFoundError:
31
+ return None
@@ -0,0 +1,309 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import json
5
+ import re
6
+ import time
7
+ from dataclasses import dataclass
8
+ from importlib import import_module
9
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Union
10
+
11
+ from django.conf import settings
12
+ from django.urls import URLPattern, URLResolver, get_resolver
13
+ from django.utils.module_loading import import_string
14
+
15
+ from apitally.client.logging import get_logger
16
+ from apitally.client.threading import ApitallyClient
17
+ from apitally.common import get_versions
18
+
19
+
20
+ if TYPE_CHECKING:
21
+ from django.http import HttpRequest, HttpResponse
22
+ from ninja import NinjaAPI
23
+
24
+
25
+ __all__ = ["ApitallyMiddleware"]
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class ApitallyMiddlewareConfig:
31
+ client_id: str
32
+ env: str
33
+ app_version: Optional[str]
34
+ identify_consumer_callback: Optional[Callable[[HttpRequest], Optional[str]]]
35
+ urlconfs: List[Optional[str]]
36
+
37
+
38
+ class ApitallyMiddleware:
39
+ config: Optional[ApitallyMiddlewareConfig] = None
40
+
41
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
42
+ self.get_response = get_response
43
+ self.drf_available = _check_import("rest_framework")
44
+ self.drf_endpoint_enumerator = None
45
+ self.ninja_available = _check_import("ninja")
46
+ self.callbacks = set()
47
+
48
+ if self.config is None:
49
+ config = getattr(settings, "APITALLY_MIDDLEWARE", {})
50
+ self.configure(**config)
51
+ assert self.config is not None
52
+
53
+ if self.drf_available:
54
+ from rest_framework.schemas.generators import EndpointEnumerator
55
+
56
+ self.drf_endpoint_enumerator = EndpointEnumerator()
57
+ if None not in self.config.urlconfs:
58
+ self.callbacks.update(_get_drf_callbacks(self.config.urlconfs))
59
+ if self.ninja_available and None not in self.config.urlconfs:
60
+ self.callbacks.update(_get_ninja_callbacks(self.config.urlconfs))
61
+
62
+ self.client = ApitallyClient(client_id=self.config.client_id, env=self.config.env)
63
+ self.client.start_sync_loop()
64
+ self.client.set_app_info(
65
+ app_info=_get_app_info(
66
+ app_version=self.config.app_version,
67
+ urlconfs=self.config.urlconfs,
68
+ )
69
+ )
70
+
71
+ @classmethod
72
+ def configure(
73
+ cls,
74
+ client_id: str,
75
+ env: str = "dev",
76
+ app_version: Optional[str] = None,
77
+ identify_consumer_callback: Optional[str] = None,
78
+ urlconf: Optional[Union[List[Optional[str]], str]] = None,
79
+ ) -> None:
80
+ cls.config = ApitallyMiddlewareConfig(
81
+ client_id=client_id,
82
+ env=env,
83
+ app_version=app_version,
84
+ identify_consumer_callback=import_string(identify_consumer_callback)
85
+ if identify_consumer_callback
86
+ else None,
87
+ urlconfs=[urlconf] if urlconf is None or isinstance(urlconf, str) else urlconf,
88
+ )
89
+
90
+ def __call__(self, request: HttpRequest) -> HttpResponse:
91
+ start_time = time.perf_counter()
92
+ response = self.get_response(request)
93
+ response_time = time.perf_counter() - start_time
94
+ path = self.get_path(request)
95
+ if request.method is not None and path is not None:
96
+ consumer = self.get_consumer(request)
97
+ try:
98
+ self.client.request_counter.add_request(
99
+ consumer=consumer,
100
+ method=request.method,
101
+ path=path,
102
+ status_code=response.status_code,
103
+ response_time=response_time,
104
+ request_size=request.headers.get("Content-Length"),
105
+ response_size=response["Content-Length"]
106
+ if response.has_header("Content-Length")
107
+ else (len(response.content) if not response.streaming else None),
108
+ )
109
+ except Exception: # pragma: no cover
110
+ logger.exception("Failed to log request metadata")
111
+ if (
112
+ response.status_code == 422
113
+ and (content_type := response.get("Content-Type")) is not None
114
+ and content_type.startswith("application/json")
115
+ ):
116
+ try:
117
+ with contextlib.suppress(json.JSONDecodeError):
118
+ body = json.loads(response.content)
119
+ if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
120
+ # Log Django Ninja / Pydantic validation errors
121
+ self.client.validation_error_counter.add_validation_errors(
122
+ consumer=consumer,
123
+ method=request.method,
124
+ path=path,
125
+ detail=body["detail"],
126
+ )
127
+ except Exception: # pragma: no cover
128
+ logger.exception("Failed to log validation errors")
129
+ return response
130
+
131
+ def get_path(self, request: HttpRequest) -> Optional[str]:
132
+ if (match := request.resolver_match) is not None:
133
+ try:
134
+ if self.callbacks and match.func not in self.callbacks:
135
+ return None
136
+ if self.drf_endpoint_enumerator is not None:
137
+ from rest_framework.schemas.generators import is_api_view
138
+
139
+ if is_api_view(match.func):
140
+ return self.drf_endpoint_enumerator.get_path_from_regex(match.route)
141
+ if self.ninja_available:
142
+ from ninja.operation import PathView
143
+
144
+ if hasattr(match.func, "__self__") and isinstance(match.func.__self__, PathView):
145
+ path = "/" + match.route.lstrip("/")
146
+ return re.sub(r"<(?:[^:]+:)?([^>:]+)>", r"{\1}", path)
147
+ except Exception: # pragma: no cover
148
+ logger.exception("Failed to get path for request")
149
+ return None
150
+
151
+ def get_consumer(self, request: HttpRequest) -> Optional[str]:
152
+ try:
153
+ if hasattr(request, "consumer_identifier"):
154
+ return str(request.consumer_identifier)
155
+ if self.config is not None and self.config.identify_consumer_callback is not None:
156
+ consumer_identifier = self.config.identify_consumer_callback(request)
157
+ if consumer_identifier is not None:
158
+ return str(consumer_identifier)
159
+ except Exception: # pragma: no cover
160
+ logger.exception("Failed to get consumer identifier for request")
161
+ return None
162
+
163
+
164
+ def _get_app_info(app_version: Optional[str], urlconfs: List[Optional[str]]) -> Dict[str, Any]:
165
+ app_info: Dict[str, Any] = {}
166
+ try:
167
+ app_info["paths"] = _get_paths(urlconfs)
168
+ except Exception: # pragma: no cover
169
+ app_info["paths"] = []
170
+ logger.exception("Failed to get paths")
171
+ try:
172
+ app_info["openapi"] = _get_openapi(urlconfs)
173
+ except Exception: # pragma: no cover
174
+ logger.exception("Failed to get OpenAPI schema")
175
+ app_info["versions"] = get_versions("django", "djangorestframework", "django-ninja", app_version=app_version)
176
+ app_info["client"] = "python:django"
177
+ return app_info
178
+
179
+
180
+ def _get_openapi(urlconfs: List[Optional[str]]) -> Optional[str]:
181
+ drf_schema = None
182
+ ninja_schema = None
183
+ with contextlib.suppress(ImportError):
184
+ drf_schema = _get_drf_schema(urlconfs)
185
+ with contextlib.suppress(ImportError):
186
+ ninja_schema = _get_ninja_schema(urlconfs)
187
+ if drf_schema is not None and ninja_schema is None:
188
+ return json.dumps(drf_schema)
189
+ elif ninja_schema is not None and drf_schema is None:
190
+ return json.dumps(ninja_schema)
191
+ return None # pragma: no cover
192
+
193
+
194
+ def _get_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
195
+ paths = []
196
+ with contextlib.suppress(ImportError):
197
+ paths.extend(_get_drf_paths(urlconfs))
198
+ with contextlib.suppress(ImportError):
199
+ paths.extend(_get_ninja_paths(urlconfs))
200
+ return paths
201
+
202
+
203
+ def _get_drf_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
204
+ from rest_framework.schemas.generators import EndpointEnumerator
205
+
206
+ enumerators = [EndpointEnumerator(urlconf=urlconf) for urlconf in urlconfs]
207
+ return [
208
+ {
209
+ "method": method.upper(),
210
+ "path": path,
211
+ }
212
+ for enumerator in enumerators
213
+ for path, method, _ in enumerator.get_api_endpoints()
214
+ if method not in ["HEAD", "OPTIONS"]
215
+ ]
216
+
217
+
218
+ def _get_drf_callbacks(urlconfs: List[Optional[str]]) -> Set[Callable]:
219
+ from rest_framework.schemas.generators import EndpointEnumerator
220
+
221
+ enumerators = [EndpointEnumerator(urlconf=urlconf) for urlconf in urlconfs]
222
+ return {callback for enumerator in enumerators for _, _, callback in enumerator.get_api_endpoints()}
223
+
224
+
225
+ def _get_drf_schema(urlconfs: List[Optional[str]]) -> Optional[Dict[str, Any]]:
226
+ from rest_framework.schemas.openapi import SchemaGenerator
227
+
228
+ schemas = []
229
+ with contextlib.suppress(AssertionError): # uritemplate and inflection must be installed for OpenAPI schema support
230
+ for urlconf in urlconfs:
231
+ generator = SchemaGenerator(urlconf=urlconf)
232
+ schema = generator.get_schema()
233
+ if schema is not None and len(schema["paths"]) > 0:
234
+ schemas.append(schema)
235
+ return None if len(schemas) != 1 else schemas[0] # type: ignore[return-value]
236
+
237
+
238
+ def _get_ninja_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
239
+ endpoints = []
240
+ for api in _get_ninja_api_instances(urlconfs=urlconfs):
241
+ schema = api.get_openapi_schema()
242
+ for path, operations in schema["paths"].items():
243
+ for method, operation in operations.items():
244
+ if method not in ["HEAD", "OPTIONS"]:
245
+ endpoints.append(
246
+ {
247
+ "method": method,
248
+ "path": path,
249
+ "summary": operation.get("summary"),
250
+ "description": operation.get("description"),
251
+ }
252
+ )
253
+ return endpoints
254
+
255
+
256
+ def _get_ninja_callbacks(urlconfs: List[Optional[str]]) -> Set[Callable]:
257
+ return {
258
+ path_view.get_view()
259
+ for api in _get_ninja_api_instances(urlconfs=urlconfs)
260
+ for _, router in api._routers
261
+ for path_view in router.path_operations.values()
262
+ }
263
+
264
+
265
+ def _get_ninja_schema(urlconfs: List[Optional[str]]) -> Optional[Dict[str, Any]]:
266
+ schemas = []
267
+ for api in _get_ninja_api_instances(urlconfs=urlconfs):
268
+ schema = api.get_openapi_schema()
269
+ if len(schema["paths"]) > 0:
270
+ schemas.append(schema)
271
+ return None if len(schemas) != 1 else schemas[0]
272
+
273
+
274
+ def _get_ninja_api_instances(
275
+ urlconfs: Optional[List[Optional[str]]] = None,
276
+ patterns: Optional[List[Any]] = None,
277
+ ) -> Set[NinjaAPI]:
278
+ from ninja import NinjaAPI
279
+
280
+ if urlconfs is None:
281
+ urlconfs = [None]
282
+ if patterns is None:
283
+ patterns = []
284
+ for urlconf in urlconfs:
285
+ patterns.extend(get_resolver(urlconf).url_patterns)
286
+
287
+ apis: Set[NinjaAPI] = set()
288
+ for p in patterns:
289
+ if isinstance(p, URLResolver):
290
+ if p.app_name != "ninja":
291
+ apis.update(_get_ninja_api_instances(patterns=p.url_patterns))
292
+ else:
293
+ for pattern in p.url_patterns:
294
+ if isinstance(pattern, URLPattern) and pattern.lookup_str.startswith("ninja."):
295
+ callback_keywords = getattr(pattern.callback, "keywords", {})
296
+ if isinstance(callback_keywords, dict):
297
+ api = callback_keywords.get("api")
298
+ if isinstance(api, NinjaAPI):
299
+ apis.add(api)
300
+ break
301
+ return apis
302
+
303
+
304
+ def _check_import(name: str) -> bool:
305
+ try:
306
+ import_module(name)
307
+ return True
308
+ except ImportError:
309
+ return False
@@ -1,8 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- import sys
4
3
  import time
5
- from importlib.metadata import version
6
4
  from threading import Timer
7
5
  from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
8
6
 
@@ -12,6 +10,7 @@ from werkzeug.exceptions import NotFound
12
10
  from werkzeug.test import Client
13
11
 
14
12
  from apitally.client.threading import ApitallyClient
13
+ from apitally.common import get_versions
15
14
 
16
15
 
17
16
  if TYPE_CHECKING:
@@ -103,7 +102,7 @@ def _get_app_info(app: Flask, app_version: Optional[str] = None, openapi_url: Op
103
102
  app_info["openapi"] = openapi
104
103
  if paths := _get_paths(app.url_map):
105
104
  app_info["paths"] = paths
106
- app_info["versions"] = _get_versions(app_version)
105
+ app_info["versions"] = get_versions("flask", app_version=app_version)
107
106
  app_info["client"] = "python:flask"
108
107
  return app_info
109
108
 
@@ -124,14 +123,3 @@ def _get_openapi(app: WSGIApplication, openapi_url: str) -> Optional[str]:
124
123
  if response.status_code != 200:
125
124
  return None
126
125
  return response.get_data(as_text=True)
127
-
128
-
129
- def _get_versions(app_version: Optional[str]) -> Dict[str, str]:
130
- versions = {
131
- "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
132
- "apitally": version("apitally"),
133
- "flask": version("flask"),
134
- }
135
- if app_version:
136
- versions["app"] = app_version
137
- return versions
@@ -1,8 +1,6 @@
1
1
  import contextlib
2
2
  import json
3
- import sys
4
3
  import time
5
- from importlib.metadata import version
6
4
  from typing import Callable, Dict, List, Optional
7
5
 
8
6
  from litestar.app import DEFAULT_OPENAPI_CONFIG, Litestar
@@ -15,6 +13,7 @@ from litestar.plugins import InitPluginProtocol
15
13
  from litestar.types import ASGIApp, Message, Receive, Scope, Send
16
14
 
17
15
  from apitally.client.asyncio import ApitallyClient
16
+ from apitally.common import get_versions
18
17
 
19
18
 
20
19
  __all__ = ["ApitallyPlugin"]
@@ -48,7 +47,7 @@ class ApitallyPlugin(InitPluginProtocol):
48
47
  app_info = {
49
48
  "openapi": _get_openapi(app),
50
49
  "paths": [route for route in _get_routes(app) if not self.filter_path(route["path"])],
51
- "versions": _get_versions(self.app_version),
50
+ "versions": get_versions("litestar", app_version=self.app_version),
52
51
  "client": "python:litestar",
53
52
  }
54
53
  self.client.set_app_info(app_info)
@@ -174,14 +173,3 @@ def _get_routes(app: Litestar) -> List[Dict[str, str]]:
174
173
  for method in route.methods
175
174
  if route.scope_type == ScopeType.HTTP and method != "OPTIONS"
176
175
  ]
177
-
178
-
179
- def _get_versions(app_version: Optional[str]) -> Dict[str, str]:
180
- versions = {
181
- "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
182
- "apitally": version("apitally"),
183
- "litestar": version("litestar"),
184
- }
185
- if app_version:
186
- versions["app"] = app_version
187
- return versions
@@ -2,9 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import json
5
- import sys
6
5
  import time
7
- from importlib.metadata import PackageNotFoundError, version
8
6
  from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union
9
7
 
10
8
  from httpx import HTTPStatusError
@@ -17,6 +15,7 @@ from starlette.testclient import TestClient
17
15
  from starlette.types import ASGIApp
18
16
 
19
17
  from apitally.client.asyncio import ApitallyClient
18
+ from apitally.common import get_versions
20
19
 
21
20
 
22
21
  if TYPE_CHECKING:
@@ -145,7 +144,7 @@ def _get_app_info(app: ASGIApp, app_version: Optional[str] = None, openapi_url:
145
144
  app_info["openapi"] = openapi
146
145
  if endpoints := _get_endpoint_info(app):
147
146
  app_info["paths"] = [{"path": endpoint.path, "method": endpoint.http_method} for endpoint in endpoints]
148
- app_info["versions"] = _get_versions(app_version)
147
+ app_info["versions"] = get_versions("fastapi", "starlette", app_version=app_version)
149
148
  app_info["client"] = "python:starlette"
150
149
  return app_info
151
150
 
@@ -179,18 +178,3 @@ def _register_shutdown_handler(app: Union[ASGIApp, Router], shutdown_handler: Ca
179
178
  app.add_event_handler("shutdown", shutdown_handler)
180
179
  elif hasattr(app, "app"):
181
180
  _register_shutdown_handler(app.app, shutdown_handler)
182
-
183
-
184
- def _get_versions(app_version: Optional[str]) -> Dict[str, str]:
185
- versions = {
186
- "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
187
- "apitally": version("apitally"),
188
- "starlette": version("starlette"),
189
- }
190
- try:
191
- versions["fastapi"] = version("fastapi")
192
- except PackageNotFoundError: # pragma: no cover
193
- pass
194
- if app_version:
195
- versions["app"] = app_version
196
- return versions
@@ -4,8 +4,8 @@ build-backend = "poetry_dynamic_versioning.backend"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "apitally"
7
- version = "0.6.3"
8
- description = "API monitoring for REST APIs built with FastAPI, Flask, Django, and Starlette."
7
+ version = "0.7.1"
8
+ description = "API monitoring for REST APIs built with FastAPI, Flask, Django, Starlette and Litestar."
9
9
  readme = "README.md"
10
10
  authors = ["Apitally <hello@apitally.io>"]
11
11
  license = "MIT License"
@@ -34,9 +34,11 @@ djangorestframework = { version = ">=3.10.0", optional = true }
34
34
  fastapi = { version = ">=0.87.0", optional = true }
35
35
  flask = { version = ">=2.0.0", optional = true }
36
36
  httpx = { version = ">=0.22.0", optional = true }
37
+ inflection = { version = ">=0.5.1", optional = true }
37
38
  litestar = { version = ">=2.0.0", optional = true }
38
39
  requests = { version = ">=2.26.0", optional = true }
39
40
  starlette = { version = ">=0.21.0,<1.0.0", optional = true }
41
+ uritemplate = { version = ">=3.0.0", optional = true }
40
42
 
41
43
  [tool.poetry.group.dev.dependencies]
42
44
  ipykernel = "^6.26.0"
@@ -65,9 +67,14 @@ types-six = "*"
65
67
  types-ujson = "*"
66
68
 
67
69
  [tool.poetry.extras]
68
- django = ["django", "requests"]
69
70
  django_ninja = ["django", "django-ninja", "requests"]
70
- django_rest_framework = ["django", "djangorestframework", "requests"]
71
+ django_rest_framework = [
72
+ "django",
73
+ "djangorestframework",
74
+ "uritemplate", # required for schema generation
75
+ "inflection", # required for schema generation
76
+ "requests",
77
+ ]
71
78
  fastapi = ["fastapi", "starlette", "httpx"]
72
79
  flask = ["flask", "requests"]
73
80
  litestar = ["litestar", "httpx"]
@@ -1 +0,0 @@
1
- __version__ = "0.6.3"
@@ -1,272 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import sys
5
- import time
6
- from dataclasses import dataclass
7
- from importlib.metadata import PackageNotFoundError, version
8
- from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
9
-
10
- from django.conf import settings
11
- from django.core.exceptions import ViewDoesNotExist
12
- from django.test import RequestFactory
13
- from django.urls import Resolver404, URLPattern, URLResolver, get_resolver, resolve
14
- from django.utils.module_loading import import_string
15
-
16
- from apitally.client.threading import ApitallyClient
17
-
18
-
19
- if TYPE_CHECKING:
20
- from django.http import HttpRequest, HttpResponse
21
-
22
-
23
- __all__ = ["ApitallyMiddleware"]
24
-
25
-
26
- @dataclass
27
- class ApitallyMiddlewareConfig:
28
- client_id: str
29
- env: str
30
- app_version: Optional[str]
31
- openapi_url: Optional[str]
32
- identify_consumer_callback: Optional[Callable[[HttpRequest], Optional[str]]]
33
-
34
-
35
- class ApitallyMiddleware:
36
- config: Optional[ApitallyMiddlewareConfig] = None
37
-
38
- def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
39
- self.get_response = get_response
40
- if self.config is None:
41
- config = getattr(settings, "APITALLY_MIDDLEWARE", {})
42
- self.configure(**config)
43
- assert self.config is not None
44
- views = _extract_views_from_url_patterns(get_resolver().url_patterns)
45
- self.view_lookup = {
46
- view.pattern: view for view in reversed(_extract_views_from_url_patterns(get_resolver().url_patterns))
47
- }
48
- self.client = ApitallyClient(client_id=self.config.client_id, env=self.config.env)
49
- self.client.start_sync_loop()
50
- self.client.set_app_info(
51
- app_info=_get_app_info(
52
- views=views,
53
- app_version=self.config.app_version,
54
- openapi_url=self.config.openapi_url,
55
- )
56
- )
57
-
58
- @classmethod
59
- def configure(
60
- cls,
61
- client_id: str,
62
- env: str = "dev",
63
- app_version: Optional[str] = None,
64
- openapi_url: Optional[str] = None,
65
- identify_consumer_callback: Optional[str] = None,
66
- ) -> None:
67
- cls.config = ApitallyMiddlewareConfig(
68
- client_id=client_id,
69
- env=env,
70
- app_version=app_version,
71
- openapi_url=openapi_url,
72
- identify_consumer_callback=import_string(identify_consumer_callback)
73
- if identify_consumer_callback
74
- else None,
75
- )
76
-
77
- def __call__(self, request: HttpRequest) -> HttpResponse:
78
- view = self.get_view(request)
79
- start_time = time.perf_counter()
80
- response = self.get_response(request)
81
- if request.method is not None and view is not None and view.is_api_view:
82
- consumer = self.get_consumer(request)
83
- self.client.request_counter.add_request(
84
- consumer=consumer,
85
- method=request.method,
86
- path=view.pattern,
87
- status_code=response.status_code,
88
- response_time=time.perf_counter() - start_time,
89
- request_size=request.headers.get("Content-Length"),
90
- response_size=response["Content-Length"]
91
- if response.has_header("Content-Length")
92
- else (len(response.content) if not response.streaming else None),
93
- )
94
- if (
95
- response.status_code == 422
96
- and (content_type := response.get("Content-Type")) is not None
97
- and content_type.startswith("application/json")
98
- ):
99
- try:
100
- body = json.loads(response.content)
101
- if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
102
- # Log Django Ninja / Pydantic validation errors
103
- self.client.validation_error_counter.add_validation_errors(
104
- consumer=consumer,
105
- method=request.method,
106
- path=view.pattern,
107
- detail=body["detail"],
108
- )
109
- except json.JSONDecodeError: # pragma: no cover
110
- pass
111
- return response
112
-
113
- def get_view(self, request: HttpRequest) -> Optional[DjangoViewInfo]:
114
- try:
115
- resolver_match = resolve(request.path_info)
116
- return self.view_lookup.get(resolver_match.route)
117
- except Resolver404: # pragma: no cover
118
- return None
119
-
120
- def get_consumer(self, request: HttpRequest) -> Optional[str]:
121
- if hasattr(request, "consumer_identifier"):
122
- return str(request.consumer_identifier)
123
- if self.config is not None and self.config.identify_consumer_callback is not None:
124
- consumer_identifier = self.config.identify_consumer_callback(request)
125
- if consumer_identifier is not None:
126
- return str(consumer_identifier)
127
- return None
128
-
129
-
130
- @dataclass
131
- class DjangoViewInfo:
132
- func: Callable
133
- pattern: str
134
- name: Optional[str] = None
135
-
136
- @property
137
- def is_api_view(self) -> bool:
138
- return self.is_rest_framework_api_view or self.is_ninja_path_view
139
-
140
- @property
141
- def is_rest_framework_api_view(self) -> bool:
142
- try:
143
- from rest_framework.views import APIView
144
-
145
- return hasattr(self.func, "view_class") and issubclass(self.func.view_class, APIView)
146
- except ImportError: # pragma: no cover
147
- return False
148
-
149
- @property
150
- def is_ninja_path_view(self) -> bool:
151
- try:
152
- from ninja.operation import PathView
153
-
154
- return hasattr(self.func, "__self__") and isinstance(self.func.__self__, PathView)
155
- except ImportError: # pragma: no cover
156
- return False
157
-
158
- @property
159
- def allowed_methods(self) -> List[str]:
160
- if hasattr(self.func, "view_class"):
161
- return [method.upper() for method in self.func.view_class().allowed_methods]
162
- if self.is_ninja_path_view:
163
- assert hasattr(self.func, "__self__")
164
- return [method.upper() for operation in self.func.__self__.operations for method in operation.methods]
165
- return [] # pragma: no cover
166
-
167
-
168
- def _get_app_info(
169
- views: List[DjangoViewInfo], app_version: Optional[str] = None, openapi_url: Optional[str] = None
170
- ) -> Dict[str, Any]:
171
- app_info: Dict[str, Any] = {}
172
- if openapi := _get_openapi(views, openapi_url):
173
- app_info["openapi"] = openapi
174
- app_info["paths"] = _get_paths(views)
175
- app_info["versions"] = _get_versions(app_version)
176
- app_info["client"] = "python:django"
177
- return app_info
178
-
179
-
180
- def _get_paths(views: List[DjangoViewInfo]) -> List[Dict[str, str]]:
181
- return [
182
- {"method": method, "path": view.pattern}
183
- for view in views
184
- if view.is_api_view
185
- for method in view.allowed_methods
186
- if method not in ["HEAD", "OPTIONS"]
187
- ]
188
-
189
-
190
- def _get_openapi(views: List[DjangoViewInfo], openapi_url: Optional[str] = None) -> Optional[str]:
191
- openapi_views = [
192
- view
193
- for view in views
194
- if (openapi_url is not None and view.pattern == openapi_url.removeprefix("/"))
195
- or (openapi_url is None and view.pattern.endswith("openapi.json") and "<" not in view.pattern)
196
- ]
197
- if len(openapi_views) == 1:
198
- rf = RequestFactory()
199
- request = rf.get(openapi_views[0].pattern)
200
- response = openapi_views[0].func(request)
201
- if response.status_code == 200:
202
- return response.content.decode()
203
- return None
204
-
205
-
206
- def _extract_views_from_url_patterns(
207
- url_patterns: List[Any], base: str = "", namespace: Optional[str] = None
208
- ) -> List[DjangoViewInfo]:
209
- # Copied and adapted from django-extensions.
210
- # See https://github.com/django-extensions/django-extensions/blob/dd794f1b239d657f62d40f2c3178200978328ed7/django_extensions/management/commands/show_urls.py#L190C34-L190C34
211
- views = []
212
- for p in url_patterns:
213
- if isinstance(p, URLPattern):
214
- try:
215
- if not p.name:
216
- name = p.name
217
- elif namespace:
218
- name = f"{namespace}:{p.name}"
219
- else:
220
- name = p.name
221
- views.append(DjangoViewInfo(func=p.callback, pattern=base + str(p.pattern), name=name))
222
- except ViewDoesNotExist:
223
- continue
224
- elif isinstance(p, URLResolver):
225
- try:
226
- patterns = p.url_patterns
227
- except ImportError:
228
- continue
229
- views.extend(
230
- _extract_views_from_url_patterns(
231
- patterns,
232
- base + str(p.pattern),
233
- namespace=f"{namespace}:{p.namespace}" if namespace and p.namespace else p.namespace or namespace,
234
- )
235
- )
236
- elif hasattr(p, "_get_callback"):
237
- try:
238
- views.append(DjangoViewInfo(func=p._get_callback(), pattern=base + str(p.pattern), name=p.name))
239
- except ViewDoesNotExist:
240
- continue
241
- elif hasattr(p, "url_patterns"):
242
- try:
243
- patterns = p.url_patterns
244
- except ImportError:
245
- continue
246
- views.extend(
247
- _extract_views_from_url_patterns(
248
- patterns,
249
- base + str(p.pattern),
250
- namespace=namespace,
251
- )
252
- )
253
- return views
254
-
255
-
256
- def _get_versions(app_version: Optional[str]) -> Dict[str, str]:
257
- versions = {
258
- "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
259
- "apitally": version("apitally"),
260
- "django": version("django"),
261
- }
262
- try:
263
- versions["djangorestframework"] = version("djangorestframework")
264
- except PackageNotFoundError: # pragma: no cover
265
- pass
266
- try:
267
- versions["django-ninja"] = version("django-ninja")
268
- except PackageNotFoundError: # pragma: no cover
269
- pass
270
- if app_version:
271
- versions["app"] = app_version
272
- return versions
File without changes
File without changes
File without changes