apitally 0.4.1__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 +5 -44
- apitally/client/base.py +8 -122
- apitally/client/threading.py +5 -44
- apitally/django.py +2 -16
- apitally/django_ninja.py +2 -82
- apitally/django_rest_framework.py +1 -45
- apitally/fastapi.py +1 -74
- apitally/flask.py +5 -47
- apitally/litestar.py +186 -0
- apitally/starlette.py +3 -71
- {apitally-0.4.1.dist-info → apitally-0.6.0.dist-info}/METADATA +39 -52
- apitally-0.6.0.dist-info/RECORD +18 -0
- apitally-0.4.1.dist-info/RECORD +0 -17
- {apitally-0.4.1.dist-info → apitally-0.6.0.dist-info}/LICENSE +0 -0
- {apitally-0.4.1.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
@@ -2,20 +2,14 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import asyncio
|
4
4
|
import logging
|
5
|
-
import sys
|
6
5
|
import time
|
7
6
|
from functools import partial
|
8
|
-
from typing import Any, Dict, Optional, Tuple
|
7
|
+
from typing import Any, Dict, Optional, Tuple
|
9
8
|
|
10
9
|
import backoff
|
11
10
|
import httpx
|
12
11
|
|
13
|
-
from apitally.client.base import
|
14
|
-
MAX_QUEUE_TIME,
|
15
|
-
REQUEST_TIMEOUT,
|
16
|
-
ApitallyClientBase,
|
17
|
-
ApitallyKeyCacheBase,
|
18
|
-
)
|
12
|
+
from apitally.client.base import MAX_QUEUE_TIME, REQUEST_TIMEOUT, ApitallyClientBase
|
19
13
|
from apitally.client.logging import get_logger
|
20
14
|
|
21
15
|
|
@@ -31,19 +25,8 @@ retry = partial(
|
|
31
25
|
|
32
26
|
|
33
27
|
class ApitallyClient(ApitallyClientBase):
|
34
|
-
def __init__(
|
35
|
-
|
36
|
-
client_id: str,
|
37
|
-
env: str,
|
38
|
-
sync_api_keys: bool = False,
|
39
|
-
key_cache_class: Optional[Type[ApitallyKeyCacheBase]] = None,
|
40
|
-
) -> None:
|
41
|
-
super().__init__(
|
42
|
-
client_id=client_id,
|
43
|
-
env=env,
|
44
|
-
sync_api_keys=sync_api_keys,
|
45
|
-
key_cache_class=key_cache_class,
|
46
|
-
)
|
28
|
+
def __init__(self, client_id: str, env: str) -> None:
|
29
|
+
super().__init__(client_id=client_id, env=env)
|
47
30
|
self._stop_sync_loop = False
|
48
31
|
self._sync_loop_task: Optional[asyncio.Task[Any]] = None
|
49
32
|
self._requests_data_queue: asyncio.Queue[Tuple[float, Dict[str, Any]]] = asyncio.Queue()
|
@@ -62,8 +45,6 @@ class ApitallyClient(ApitallyClientBase):
|
|
62
45
|
time_start = time.perf_counter()
|
63
46
|
async with self.get_http_client() as client:
|
64
47
|
tasks = [self.send_requests_data(client)]
|
65
|
-
if self.sync_api_keys:
|
66
|
-
tasks.append(self.get_keys(client))
|
67
48
|
if not self._app_info_sent and not first_iteration:
|
68
49
|
tasks.append(self.send_app_info(client))
|
69
50
|
await asyncio.gather(*tasks)
|
@@ -113,24 +94,11 @@ class ApitallyClient(ApitallyClientBase):
|
|
113
94
|
for item in failed_items:
|
114
95
|
self._requests_data_queue.put_nowait(item)
|
115
96
|
|
116
|
-
async def get_keys(self, client: httpx.AsyncClient) -> None:
|
117
|
-
if response_data := await self._get_keys(client): # Response data can be None if backoff gives up
|
118
|
-
self.handle_keys_response(response_data)
|
119
|
-
self._keys_updated_at = time.time()
|
120
|
-
elif self.key_registry.salt is None: # pragma: no cover
|
121
|
-
logger.critical("Initial Apitally API key sync failed")
|
122
|
-
# Exit because the application will not be able to authenticate requests
|
123
|
-
sys.exit(1)
|
124
|
-
elif (self._keys_updated_at is not None and time.time() - self._keys_updated_at > MAX_QUEUE_TIME) or (
|
125
|
-
self._keys_updated_at is None and time.time() - self._started_at > MAX_QUEUE_TIME
|
126
|
-
):
|
127
|
-
logger.error("Apitally API key sync has been failing for more than 1 hour")
|
128
|
-
|
129
97
|
@retry(raise_on_giveup=False)
|
130
98
|
async def _send_app_info(self, client: httpx.AsyncClient, payload: Dict[str, Any]) -> None:
|
131
99
|
logger.debug("Sending app info")
|
132
100
|
response = await client.post(url="/info", json=payload, timeout=REQUEST_TIMEOUT)
|
133
|
-
if response.status_code == 404
|
101
|
+
if response.status_code == 404:
|
134
102
|
self.stop_sync_loop()
|
135
103
|
logger.error(f"Invalid Apitally client ID {self.client_id}")
|
136
104
|
else:
|
@@ -143,10 +111,3 @@ class ApitallyClient(ApitallyClientBase):
|
|
143
111
|
logger.debug("Sending requests data")
|
144
112
|
response = await client.post(url="/requests", json=payload)
|
145
113
|
response.raise_for_status()
|
146
|
-
|
147
|
-
@retry(raise_on_giveup=False)
|
148
|
-
async def _get_keys(self, client: httpx.AsyncClient) -> Dict[str, Any]:
|
149
|
-
logger.debug("Updating API keys")
|
150
|
-
response = await client.get(url="/keys")
|
151
|
-
response.raise_for_status()
|
152
|
-
return response.json()
|
apitally/client/base.py
CHANGED
@@ -1,17 +1,14 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import json
|
4
3
|
import os
|
5
4
|
import re
|
6
5
|
import threading
|
7
6
|
import time
|
8
|
-
from abc import ABC
|
7
|
+
from abc import ABC
|
9
8
|
from collections import Counter
|
10
|
-
from dataclasses import dataclass
|
11
|
-
from datetime import datetime, timedelta
|
12
|
-
from hashlib import scrypt
|
9
|
+
from dataclasses import dataclass
|
13
10
|
from math import floor
|
14
|
-
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar,
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, cast
|
15
12
|
from uuid import UUID, uuid4
|
16
13
|
|
17
14
|
from apitally.client.logging import get_logger
|
@@ -30,24 +27,18 @@ INITIAL_SYNC_INTERVAL_DURATION = 3600
|
|
30
27
|
TApitallyClient = TypeVar("TApitallyClient", bound="ApitallyClientBase")
|
31
28
|
|
32
29
|
|
33
|
-
class ApitallyClientBase:
|
30
|
+
class ApitallyClientBase(ABC):
|
34
31
|
_instance: Optional[ApitallyClientBase] = None
|
35
32
|
_lock = threading.Lock()
|
36
33
|
|
37
|
-
def __new__(cls, *args, **kwargs) ->
|
34
|
+
def __new__(cls: Type[TApitallyClient], *args, **kwargs) -> TApitallyClient:
|
38
35
|
if cls._instance is None:
|
39
36
|
with cls._lock:
|
40
37
|
if cls._instance is None:
|
41
38
|
cls._instance = super().__new__(cls)
|
42
|
-
return cls._instance
|
39
|
+
return cast(TApitallyClient, cls._instance)
|
43
40
|
|
44
|
-
def __init__(
|
45
|
-
self,
|
46
|
-
client_id: str,
|
47
|
-
env: str,
|
48
|
-
sync_api_keys: bool = False,
|
49
|
-
key_cache_class: Optional[Type[ApitallyKeyCacheBase]] = None,
|
50
|
-
) -> None:
|
41
|
+
def __init__(self, client_id: str, env: str) -> None:
|
51
42
|
if hasattr(self, "client_id"):
|
52
43
|
raise RuntimeError("Apitally client is already initialized") # pragma: no cover
|
53
44
|
try:
|
@@ -59,23 +50,13 @@ class ApitallyClientBase:
|
|
59
50
|
|
60
51
|
self.client_id = client_id
|
61
52
|
self.env = env
|
62
|
-
self.sync_api_keys = sync_api_keys
|
63
53
|
self.instance_uuid = str(uuid4())
|
64
54
|
self.request_counter = RequestCounter()
|
65
55
|
self.validation_error_counter = ValidationErrorCounter()
|
66
|
-
self.key_registry = KeyRegistry()
|
67
|
-
self.key_cache = key_cache_class(client_id=client_id, env=env) if key_cache_class is not None else None
|
68
56
|
|
69
57
|
self._app_info_payload: Optional[Dict[str, Any]] = None
|
70
58
|
self._app_info_sent = False
|
71
59
|
self._started_at = time.time()
|
72
|
-
self._keys_updated_at: Optional[float] = None
|
73
|
-
|
74
|
-
if self.key_cache is not None and (key_data := self.key_cache.retrieve()):
|
75
|
-
try:
|
76
|
-
self.handle_keys_response(json.loads(key_data), cache=False)
|
77
|
-
except (json.JSONDecodeError, TypeError, KeyError): # pragma: no cover
|
78
|
-
logger.exception("Failed to load API keys from cache")
|
79
60
|
|
80
61
|
@classmethod
|
81
62
|
def get_instance(cls: Type[TApitallyClient]) -> TApitallyClient:
|
@@ -104,42 +85,13 @@ class ApitallyClientBase:
|
|
104
85
|
def get_requests_payload(self) -> Dict[str, Any]:
|
105
86
|
requests = self.request_counter.get_and_reset_requests()
|
106
87
|
validation_errors = self.validation_error_counter.get_and_reset_validation_errors()
|
107
|
-
api_key_usage = self.key_registry.get_and_reset_usage_counts() if self.sync_api_keys else {}
|
108
88
|
return {
|
109
89
|
"instance_uuid": self.instance_uuid,
|
110
90
|
"message_uuid": str(uuid4()),
|
111
91
|
"requests": requests,
|
112
92
|
"validation_errors": validation_errors,
|
113
|
-
"api_key_usage": api_key_usage,
|
114
93
|
}
|
115
94
|
|
116
|
-
def handle_keys_response(self, response_data: Dict[str, Any], cache: bool = True) -> None:
|
117
|
-
self.key_registry.salt = response_data["salt"]
|
118
|
-
self.key_registry.update(response_data["keys"])
|
119
|
-
|
120
|
-
if cache and self.key_cache is not None:
|
121
|
-
self.key_cache.store(json.dumps(response_data, check_circular=False, allow_nan=False))
|
122
|
-
|
123
|
-
|
124
|
-
class ApitallyKeyCacheBase(ABC):
|
125
|
-
def __init__(self, client_id: str, env: str) -> None:
|
126
|
-
self.client_id = client_id
|
127
|
-
self.env = env
|
128
|
-
|
129
|
-
@property
|
130
|
-
def cache_key(self) -> str:
|
131
|
-
return f"apitally:keys:{self.client_id}:{self.env}"
|
132
|
-
|
133
|
-
@abstractmethod
|
134
|
-
def store(self, data: str) -> None:
|
135
|
-
"""Store the key data in cache as a JSON string."""
|
136
|
-
pass # pragma: no cover
|
137
|
-
|
138
|
-
@abstractmethod
|
139
|
-
def retrieve(self) -> str | bytes | bytearray | None:
|
140
|
-
"""Retrieve the stored key data from the cache as a JSON string."""
|
141
|
-
pass # pragma: no cover
|
142
|
-
|
143
95
|
|
144
96
|
@dataclass(frozen=True)
|
145
97
|
class RequestInfo:
|
@@ -243,8 +195,8 @@ class ValidationErrorCounter:
|
|
243
195
|
method=method.upper(),
|
244
196
|
path=path,
|
245
197
|
loc=tuple(str(loc) for loc in error["loc"]),
|
246
|
-
type=error["type"],
|
247
198
|
msg=error["msg"],
|
199
|
+
type=error["type"],
|
248
200
|
)
|
249
201
|
self.error_counts[validation_error] += 1
|
250
202
|
except (KeyError, TypeError): # pragma: no cover
|
@@ -267,69 +219,3 @@ class ValidationErrorCounter:
|
|
267
219
|
)
|
268
220
|
self.error_counts.clear()
|
269
221
|
return data
|
270
|
-
|
271
|
-
|
272
|
-
@dataclass(frozen=True)
|
273
|
-
class KeyInfo:
|
274
|
-
key_id: int
|
275
|
-
api_key_id: int
|
276
|
-
name: str = ""
|
277
|
-
scopes: List[str] = field(default_factory=list)
|
278
|
-
expires_at: Optional[datetime] = None
|
279
|
-
|
280
|
-
@property
|
281
|
-
def is_expired(self) -> bool:
|
282
|
-
return self.expires_at is not None and self.expires_at < datetime.now()
|
283
|
-
|
284
|
-
def has_scopes(self, scopes: Union[List[str], str]) -> bool:
|
285
|
-
if isinstance(scopes, str):
|
286
|
-
scopes = [scopes]
|
287
|
-
if not isinstance(scopes, list):
|
288
|
-
raise ValueError("scopes must be a string or a list of strings")
|
289
|
-
return all(scope in self.scopes for scope in scopes)
|
290
|
-
|
291
|
-
@classmethod
|
292
|
-
def from_dict(cls, data: Dict[str, Any]) -> KeyInfo:
|
293
|
-
return cls(
|
294
|
-
key_id=data["key_id"],
|
295
|
-
api_key_id=data["api_key_id"],
|
296
|
-
name=data.get("name", ""),
|
297
|
-
scopes=data.get("scopes", []),
|
298
|
-
expires_at=(
|
299
|
-
datetime.now() + timedelta(seconds=data["expires_in_seconds"])
|
300
|
-
if data["expires_in_seconds"] is not None
|
301
|
-
else None
|
302
|
-
),
|
303
|
-
)
|
304
|
-
|
305
|
-
|
306
|
-
class KeyRegistry:
|
307
|
-
def __init__(self) -> None:
|
308
|
-
self.salt: Optional[str] = None
|
309
|
-
self.keys: Dict[str, KeyInfo] = {}
|
310
|
-
self.usage_counts: Counter[int] = Counter()
|
311
|
-
self._lock = threading.Lock()
|
312
|
-
|
313
|
-
def get(self, api_key: str) -> Optional[KeyInfo]:
|
314
|
-
hash = self.hash_api_key(api_key.strip())
|
315
|
-
with self._lock:
|
316
|
-
key = self.keys.get(hash)
|
317
|
-
if key is None or key.is_expired:
|
318
|
-
return None
|
319
|
-
self.usage_counts[key.api_key_id] += 1
|
320
|
-
return key
|
321
|
-
|
322
|
-
def hash_api_key(self, api_key: str) -> str:
|
323
|
-
if self.salt is None:
|
324
|
-
raise RuntimeError("Apitally API keys not initialized")
|
325
|
-
return scrypt(api_key.encode(), salt=bytes.fromhex(self.salt), n=256, r=4, p=1, dklen=32).hex()
|
326
|
-
|
327
|
-
def update(self, keys: Dict[str, Dict[str, Any]]) -> None:
|
328
|
-
with self._lock:
|
329
|
-
self.keys = {hash: KeyInfo.from_dict(data) for hash, data in keys.items()}
|
330
|
-
|
331
|
-
def get_and_reset_usage_counts(self) -> Dict[int, int]:
|
332
|
-
with self._lock:
|
333
|
-
data = dict(self.usage_counts)
|
334
|
-
self.usage_counts.clear()
|
335
|
-
return data
|
apitally/client/threading.py
CHANGED
@@ -2,21 +2,15 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import logging
|
4
4
|
import queue
|
5
|
-
import sys
|
6
5
|
import time
|
7
6
|
from functools import partial
|
8
7
|
from threading import Event, Thread
|
9
|
-
from typing import Any, Callable, Dict, Optional, Tuple
|
8
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
10
9
|
|
11
10
|
import backoff
|
12
11
|
import requests
|
13
12
|
|
14
|
-
from apitally.client.base import
|
15
|
-
MAX_QUEUE_TIME,
|
16
|
-
REQUEST_TIMEOUT,
|
17
|
-
ApitallyClientBase,
|
18
|
-
ApitallyKeyCacheBase,
|
19
|
-
)
|
13
|
+
from apitally.client.base import MAX_QUEUE_TIME, REQUEST_TIMEOUT, ApitallyClientBase
|
20
14
|
from apitally.client.logging import get_logger
|
21
15
|
|
22
16
|
|
@@ -48,19 +42,8 @@ except NameError:
|
|
48
42
|
|
49
43
|
|
50
44
|
class ApitallyClient(ApitallyClientBase):
|
51
|
-
def __init__(
|
52
|
-
|
53
|
-
client_id: str,
|
54
|
-
env: str,
|
55
|
-
sync_api_keys: bool = False,
|
56
|
-
key_cache_class: Optional[Type[ApitallyKeyCacheBase]] = None,
|
57
|
-
) -> None:
|
58
|
-
super().__init__(
|
59
|
-
client_id=client_id,
|
60
|
-
env=env,
|
61
|
-
sync_api_keys=sync_api_keys,
|
62
|
-
key_cache_class=key_cache_class,
|
63
|
-
)
|
45
|
+
def __init__(self, client_id: str, env: str) -> None:
|
46
|
+
super().__init__(client_id=client_id, env=env)
|
64
47
|
self._thread: Optional[Thread] = None
|
65
48
|
self._stop_sync_loop = Event()
|
66
49
|
self._requests_data_queue: queue.Queue[Tuple[float, Dict[str, Any]]] = queue.Queue()
|
@@ -80,8 +63,6 @@ class ApitallyClient(ApitallyClientBase):
|
|
80
63
|
now = time.time()
|
81
64
|
if (now - last_sync_time) >= self.sync_interval:
|
82
65
|
with requests.Session() as session:
|
83
|
-
if self.sync_api_keys:
|
84
|
-
self.get_keys(session)
|
85
66
|
if not self._app_info_sent and last_sync_time > 0: # not on first sync
|
86
67
|
self.send_app_info(session)
|
87
68
|
self.send_requests_data(session)
|
@@ -127,24 +108,11 @@ class ApitallyClient(ApitallyClientBase):
|
|
127
108
|
for item in failed_items:
|
128
109
|
self._requests_data_queue.put_nowait(item)
|
129
110
|
|
130
|
-
def get_keys(self, session: requests.Session) -> None:
|
131
|
-
if response_data := self._get_keys(session): # Response data can be None if backoff gives up
|
132
|
-
self.handle_keys_response(response_data)
|
133
|
-
self._keys_updated_at = time.time()
|
134
|
-
elif self.key_registry.salt is None: # pragma: no cover
|
135
|
-
logger.critical("Initial Apitally API key sync failed")
|
136
|
-
# Exit because the application will not be able to authenticate requests
|
137
|
-
sys.exit(1)
|
138
|
-
elif (self._keys_updated_at is not None and time.time() - self._keys_updated_at > MAX_QUEUE_TIME) or (
|
139
|
-
self._keys_updated_at is None and time.time() - self._started_at > MAX_QUEUE_TIME
|
140
|
-
):
|
141
|
-
logger.error("Apitally API key sync has been failing for more than 1 hour")
|
142
|
-
|
143
111
|
@retry(raise_on_giveup=False)
|
144
112
|
def _send_app_info(self, session: requests.Session, payload: Dict[str, Any]) -> None:
|
145
113
|
logger.debug("Sending app info")
|
146
114
|
response = session.post(url=f"{self.hub_url}/info", json=payload, timeout=REQUEST_TIMEOUT)
|
147
|
-
if response.status_code == 404
|
115
|
+
if response.status_code == 404:
|
148
116
|
self.stop_sync_loop()
|
149
117
|
logger.error(f"Invalid Apitally client ID {self.client_id}")
|
150
118
|
else:
|
@@ -157,10 +125,3 @@ class ApitallyClient(ApitallyClientBase):
|
|
157
125
|
logger.debug("Sending requests data")
|
158
126
|
response = session.post(url=f"{self.hub_url}/requests", json=payload, timeout=REQUEST_TIMEOUT)
|
159
127
|
response.raise_for_status()
|
160
|
-
|
161
|
-
@retry(raise_on_giveup=False)
|
162
|
-
def _get_keys(self, session: requests.Session) -> Dict[str, Any]:
|
163
|
-
logger.debug("Updating API keys")
|
164
|
-
response = session.get(url=f"{self.hub_url}/keys", timeout=REQUEST_TIMEOUT)
|
165
|
-
response.raise_for_status()
|
166
|
-
return response.json()
|
apitally/django.py
CHANGED
@@ -5,7 +5,7 @@ import sys
|
|
5
5
|
import time
|
6
6
|
from dataclasses import dataclass
|
7
7
|
from importlib.metadata import PackageNotFoundError, version
|
8
|
-
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
|
9
9
|
|
10
10
|
from django.conf import settings
|
11
11
|
from django.core.exceptions import ViewDoesNotExist
|
@@ -13,7 +13,6 @@ from django.test import RequestFactory
|
|
13
13
|
from django.urls import URLPattern, URLResolver, get_resolver, resolve
|
14
14
|
from django.utils.module_loading import import_string
|
15
15
|
|
16
|
-
from apitally.client.base import ApitallyKeyCacheBase, KeyInfo
|
17
16
|
from apitally.client.threading import ApitallyClient
|
18
17
|
|
19
18
|
|
@@ -29,10 +28,8 @@ class ApitallyMiddlewareConfig:
|
|
29
28
|
client_id: str
|
30
29
|
env: str
|
31
30
|
app_version: Optional[str]
|
32
|
-
sync_api_keys: bool
|
33
31
|
openapi_url: Optional[str]
|
34
32
|
identify_consumer_callback: Optional[Callable[[HttpRequest], Optional[str]]]
|
35
|
-
key_cache_class: Optional[Type[ApitallyKeyCacheBase]]
|
36
33
|
|
37
34
|
|
38
35
|
class ApitallyMiddleware:
|
@@ -45,12 +42,7 @@ class ApitallyMiddleware:
|
|
45
42
|
self.configure(**config)
|
46
43
|
assert self.config is not None
|
47
44
|
self.views = _extract_views_from_url_patterns(get_resolver().url_patterns)
|
48
|
-
self.client = ApitallyClient(
|
49
|
-
client_id=self.config.client_id,
|
50
|
-
env=self.config.env,
|
51
|
-
sync_api_keys=self.config.sync_api_keys,
|
52
|
-
key_cache_class=self.config.key_cache_class,
|
53
|
-
)
|
45
|
+
self.client = ApitallyClient(client_id=self.config.client_id, env=self.config.env)
|
54
46
|
self.client.start_sync_loop()
|
55
47
|
self.client.set_app_info(
|
56
48
|
app_info=_get_app_info(
|
@@ -66,21 +58,17 @@ class ApitallyMiddleware:
|
|
66
58
|
client_id: str,
|
67
59
|
env: str = "dev",
|
68
60
|
app_version: Optional[str] = None,
|
69
|
-
sync_api_keys: bool = False,
|
70
61
|
openapi_url: Optional[str] = None,
|
71
62
|
identify_consumer_callback: Optional[str] = None,
|
72
|
-
key_cache_class: Optional[Type[ApitallyKeyCacheBase]] = None,
|
73
63
|
) -> None:
|
74
64
|
cls.config = ApitallyMiddlewareConfig(
|
75
65
|
client_id=client_id,
|
76
66
|
env=env,
|
77
67
|
app_version=app_version,
|
78
|
-
sync_api_keys=sync_api_keys,
|
79
68
|
openapi_url=openapi_url,
|
80
69
|
identify_consumer_callback=import_string(identify_consumer_callback)
|
81
70
|
if identify_consumer_callback
|
82
71
|
else None,
|
83
|
-
key_cache_class=key_cache_class,
|
84
72
|
)
|
85
73
|
|
86
74
|
def __call__(self, request: HttpRequest) -> HttpResponse:
|
@@ -130,8 +118,6 @@ class ApitallyMiddleware:
|
|
130
118
|
consumer_identifier = self.config.identify_consumer_callback(request)
|
131
119
|
if consumer_identifier is not None:
|
132
120
|
return str(consumer_identifier)
|
133
|
-
if hasattr(request, "auth") and isinstance(request.auth, KeyInfo):
|
134
|
-
return f"key:{request.auth.key_id}"
|
135
121
|
return None
|
136
122
|
|
137
123
|
|
apitally/django_ninja.py
CHANGED
@@ -1,84 +1,4 @@
|
|
1
|
-
from
|
1
|
+
from apitally.django import ApitallyMiddleware
|
2
2
|
|
3
|
-
from typing import TYPE_CHECKING, Callable, List, Optional
|
4
3
|
|
5
|
-
|
6
|
-
|
7
|
-
from apitally.client.asyncio import ApitallyClient
|
8
|
-
from apitally.client.base import KeyInfo
|
9
|
-
from apitally.django import ApitallyMiddleware as _ApitallyMiddleware
|
10
|
-
from apitally.django import DjangoViewInfo
|
11
|
-
|
12
|
-
|
13
|
-
if TYPE_CHECKING:
|
14
|
-
from django.http import HttpRequest, HttpResponse
|
15
|
-
from ninja import NinjaAPI
|
16
|
-
|
17
|
-
|
18
|
-
__all__ = ["ApitallyMiddleware", "APIKeyAuth", "APIKeyAuthBase", "KeyInfo"]
|
19
|
-
|
20
|
-
|
21
|
-
class ApitallyMiddleware(_ApitallyMiddleware):
|
22
|
-
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
23
|
-
super().__init__(get_response)
|
24
|
-
if self.client.sync_api_keys:
|
25
|
-
api = _get_api(self.views)
|
26
|
-
_add_exception_handlers(api)
|
27
|
-
|
28
|
-
|
29
|
-
class AuthError(Exception):
|
30
|
-
pass
|
31
|
-
|
32
|
-
|
33
|
-
class InvalidAPIKey(AuthError):
|
34
|
-
pass
|
35
|
-
|
36
|
-
|
37
|
-
class PermissionDenied(AuthError):
|
38
|
-
pass
|
39
|
-
|
40
|
-
|
41
|
-
class APIKeyAuthBase(APIKeyHeader):
|
42
|
-
def __init__(self, scopes: Optional[List[str]] = None) -> None:
|
43
|
-
self.scopes = scopes or []
|
44
|
-
|
45
|
-
def authenticate(self, request: HttpRequest, key: Optional[str]) -> Optional[KeyInfo]:
|
46
|
-
if key is None:
|
47
|
-
return None
|
48
|
-
if self.param_name == "Authorization":
|
49
|
-
scheme, _, api_key = key.partition(" ")
|
50
|
-
if scheme.lower() != "apikey":
|
51
|
-
return None
|
52
|
-
else:
|
53
|
-
api_key = key
|
54
|
-
key_info = ApitallyClient.get_instance().key_registry.get(api_key)
|
55
|
-
if key_info is None:
|
56
|
-
raise InvalidAPIKey()
|
57
|
-
if not key_info.has_scopes(self.scopes):
|
58
|
-
raise PermissionDenied()
|
59
|
-
return key_info
|
60
|
-
|
61
|
-
|
62
|
-
class APIKeyAuth(APIKeyAuthBase):
|
63
|
-
param_name = "Authorization"
|
64
|
-
openapi_description = "Provide your API key using the <code>Authorization</code> header and the scheme prefix <code>ApiKey</code>.<br>Example: <pre>Authorization: ApiKey your_api_key_here</pre>"
|
65
|
-
|
66
|
-
|
67
|
-
def _get_api(views: List[DjangoViewInfo]) -> NinjaAPI:
|
68
|
-
try:
|
69
|
-
return next(
|
70
|
-
(view.func.__self__.api for view in views if view.is_ninja_path_view and hasattr(view.func, "__self__"))
|
71
|
-
)
|
72
|
-
except StopIteration: # pragma: no cover
|
73
|
-
raise RuntimeError("Could not find NinjaAPI instance")
|
74
|
-
|
75
|
-
|
76
|
-
def _add_exception_handlers(api: NinjaAPI) -> None:
|
77
|
-
def on_invalid_api_key(request: HttpRequest, exc) -> HttpResponse:
|
78
|
-
return api.create_response(request, {"detail": "Invalid API key"}, status=403)
|
79
|
-
|
80
|
-
def on_permission_denied(request: HttpRequest, exc) -> HttpResponse:
|
81
|
-
return api.create_response(request, {"detail": "Permission denied"}, status=403)
|
82
|
-
|
83
|
-
api.add_exception_handler(InvalidAPIKey, on_invalid_api_key)
|
84
|
-
api.add_exception_handler(PermissionDenied, on_permission_denied)
|
4
|
+
__all__ = ["ApitallyMiddleware"]
|
@@ -1,48 +1,4 @@
|
|
1
|
-
from __future__ import annotations
|
2
|
-
|
3
|
-
from typing import TYPE_CHECKING, List, Type
|
4
|
-
|
5
|
-
from django.conf import settings
|
6
|
-
from rest_framework.permissions import BasePermission
|
7
|
-
|
8
|
-
from apitally.client.base import KeyInfo
|
9
|
-
from apitally.client.threading import ApitallyClient
|
10
1
|
from apitally.django import ApitallyMiddleware
|
11
2
|
|
12
3
|
|
13
|
-
|
14
|
-
from rest_framework.request import Request
|
15
|
-
from rest_framework.views import APIView
|
16
|
-
|
17
|
-
|
18
|
-
__all__ = ["ApitallyMiddleware", "HasAPIKey", "HasAPIKeyWithScopes", "KeyInfo"]
|
19
|
-
|
20
|
-
|
21
|
-
class HasAPIKey(BasePermission): # type: ignore[misc]
|
22
|
-
required_scopes: List[str] = []
|
23
|
-
|
24
|
-
def has_permission(self, request: Request, view: APIView) -> bool:
|
25
|
-
custom_header = getattr(settings, "APITALLY_CUSTOM_API_KEY_HEADER", None)
|
26
|
-
header = request.headers.get("Authorization" if custom_header is None else custom_header)
|
27
|
-
if not header:
|
28
|
-
return False
|
29
|
-
if custom_header is None:
|
30
|
-
scheme, _, api_key = header.partition(" ")
|
31
|
-
if scheme.lower() != "apikey":
|
32
|
-
return False
|
33
|
-
else:
|
34
|
-
api_key = header
|
35
|
-
key_info = ApitallyClient.get_instance().key_registry.get(api_key)
|
36
|
-
if key_info is None:
|
37
|
-
return False
|
38
|
-
if self.required_scopes and not key_info.has_scopes(self.required_scopes):
|
39
|
-
return False
|
40
|
-
request.auth = key_info
|
41
|
-
return True
|
42
|
-
|
43
|
-
|
44
|
-
def HasAPIKeyWithScopes(scopes: List[str]) -> Type[HasAPIKey]:
|
45
|
-
class _HasAPIKeyWithScopes(HasAPIKey): # type: ignore[misc]
|
46
|
-
required_scopes = scopes
|
47
|
-
|
48
|
-
return _HasAPIKeyWithScopes
|
4
|
+
__all__ = ["ApitallyMiddleware"]
|
apitally/fastapi.py
CHANGED
@@ -1,77 +1,4 @@
|
|
1
|
-
from typing import Optional
|
2
|
-
|
3
|
-
from fastapi.exceptions import HTTPException
|
4
|
-
from fastapi.openapi.models import APIKey, APIKeyIn
|
5
|
-
from fastapi.requests import Request
|
6
|
-
from fastapi.security import SecurityScopes
|
7
|
-
from fastapi.security.base import SecurityBase
|
8
|
-
from fastapi.security.utils import get_authorization_scheme_param
|
9
|
-
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
|
10
|
-
|
11
|
-
from apitally.client.asyncio import ApitallyClient
|
12
|
-
from apitally.client.base import KeyInfo
|
13
1
|
from apitally.starlette import ApitallyMiddleware
|
14
2
|
|
15
3
|
|
16
|
-
__all__ = ["ApitallyMiddleware"
|
17
|
-
|
18
|
-
|
19
|
-
class APIKeyAuth(SecurityBase):
|
20
|
-
def __init__(self, *, custom_header: Optional[str] = None, auto_error: bool = True) -> None:
|
21
|
-
self.model: APIKey
|
22
|
-
if custom_header is None:
|
23
|
-
self.model = APIKey(
|
24
|
-
**{"in": APIKeyIn.header}, # type: ignore[arg-type]
|
25
|
-
name="Authorization",
|
26
|
-
description="Provide your API key using the <code>Authorization</code> header and the scheme prefix <code>ApiKey</code>.<br>Example: <pre>Authorization: ApiKey your_api_key_here</pre>",
|
27
|
-
)
|
28
|
-
self.scheme_name = "Authorization header with ApiKey scheme"
|
29
|
-
else:
|
30
|
-
self.model = APIKey(
|
31
|
-
**{"in": APIKeyIn.header}, # type: ignore[arg-type]
|
32
|
-
name=custom_header,
|
33
|
-
description=f"Provide your API key using the <code>{custom_header}</code> header.<br>Example: <pre>{custom_header}: your_api_key_here</pre>",
|
34
|
-
)
|
35
|
-
self.scheme_name = f"{custom_header} header"
|
36
|
-
self.auto_error = auto_error
|
37
|
-
|
38
|
-
async def __call__(self, request: Request, security_scopes: SecurityScopes) -> Optional[KeyInfo]:
|
39
|
-
header = request.headers.get(self.model.name)
|
40
|
-
if self.model.name == "Authorization":
|
41
|
-
scheme, api_key = get_authorization_scheme_param(header)
|
42
|
-
if not header or scheme.lower() != "apikey":
|
43
|
-
if self.auto_error:
|
44
|
-
raise HTTPException(
|
45
|
-
status_code=HTTP_401_UNAUTHORIZED,
|
46
|
-
detail="Not authenticated" if not header else "Invalid authorization scheme",
|
47
|
-
headers={"WWW-Authenticate": "ApiKey"},
|
48
|
-
)
|
49
|
-
else:
|
50
|
-
return None # pragma: no cover
|
51
|
-
elif not header:
|
52
|
-
if self.auto_error:
|
53
|
-
raise HTTPException(
|
54
|
-
status_code=HTTP_403_FORBIDDEN,
|
55
|
-
detail="Missing API key",
|
56
|
-
)
|
57
|
-
else:
|
58
|
-
return None # pragma: no cover
|
59
|
-
else:
|
60
|
-
api_key = header
|
61
|
-
key_info = ApitallyClient.get_instance().key_registry.get(api_key)
|
62
|
-
if key_info is None and self.auto_error:
|
63
|
-
raise HTTPException(
|
64
|
-
status_code=HTTP_403_FORBIDDEN,
|
65
|
-
detail="Invalid API key",
|
66
|
-
)
|
67
|
-
if key_info is not None and self.auto_error and not key_info.has_scopes(security_scopes.scopes):
|
68
|
-
raise HTTPException(
|
69
|
-
status_code=HTTP_403_FORBIDDEN,
|
70
|
-
detail="Permission denied",
|
71
|
-
)
|
72
|
-
if key_info is not None:
|
73
|
-
request.state.key_info = key_info
|
74
|
-
return key_info
|
75
|
-
|
76
|
-
|
77
|
-
api_key_auth = APIKeyAuth()
|
4
|
+
__all__ = ["ApitallyMiddleware"]
|
apitally/flask.py
CHANGED
@@ -2,17 +2,15 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import sys
|
4
4
|
import time
|
5
|
-
from functools import wraps
|
6
5
|
from importlib.metadata import version
|
7
6
|
from threading import Timer
|
8
|
-
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
|
9
8
|
|
10
|
-
from flask import Flask, g
|
9
|
+
from flask import Flask, g
|
11
10
|
from werkzeug.datastructures import Headers
|
12
11
|
from werkzeug.exceptions import NotFound
|
13
12
|
from werkzeug.test import Client
|
14
13
|
|
15
|
-
from apitally.client.base import ApitallyKeyCacheBase, KeyInfo
|
16
14
|
from apitally.client.threading import ApitallyClient
|
17
15
|
|
18
16
|
|
@@ -21,7 +19,7 @@ if TYPE_CHECKING:
|
|
21
19
|
from werkzeug.routing.map import Map
|
22
20
|
|
23
21
|
|
24
|
-
__all__ = ["ApitallyMiddleware"
|
22
|
+
__all__ = ["ApitallyMiddleware"]
|
25
23
|
|
26
24
|
|
27
25
|
class ApitallyMiddleware:
|
@@ -31,20 +29,13 @@ class ApitallyMiddleware:
|
|
31
29
|
client_id: str,
|
32
30
|
env: str = "dev",
|
33
31
|
app_version: Optional[str] = None,
|
34
|
-
sync_api_keys: bool = False,
|
35
32
|
openapi_url: Optional[str] = None,
|
36
33
|
filter_unhandled_paths: bool = True,
|
37
|
-
key_cache_class: Optional[Type[ApitallyKeyCacheBase]] = None,
|
38
34
|
) -> None:
|
39
35
|
self.app = app
|
40
36
|
self.wsgi_app = app.wsgi_app
|
41
37
|
self.filter_unhandled_paths = filter_unhandled_paths
|
42
|
-
self.client = ApitallyClient(
|
43
|
-
client_id=client_id,
|
44
|
-
env=env,
|
45
|
-
sync_api_keys=sync_api_keys,
|
46
|
-
key_cache_class=key_cache_class,
|
47
|
-
)
|
38
|
+
self.client = ApitallyClient(client_id=client_id, env=env)
|
48
39
|
self.client.start_sync_loop()
|
49
40
|
self.delayed_set_app_info(app_version, openapi_url)
|
50
41
|
|
@@ -103,40 +94,7 @@ class ApitallyMiddleware:
|
|
103
94
|
return environ["PATH_INFO"], False
|
104
95
|
|
105
96
|
def get_consumer(self) -> Optional[str]:
|
106
|
-
if "consumer_identifier" in g
|
107
|
-
return str(g.consumer_identifier)
|
108
|
-
if "key_info" in g and isinstance(g.key_info, KeyInfo):
|
109
|
-
return f"key:{g.key_info.key_id}"
|
110
|
-
return None
|
111
|
-
|
112
|
-
|
113
|
-
def require_api_key(func=None, *, scopes: Optional[List[str]] = None, custom_header: Optional[str] = None):
|
114
|
-
def decorator(func):
|
115
|
-
@wraps(func)
|
116
|
-
def wrapped_func(*args, **kwargs):
|
117
|
-
api_key: Optional[str]
|
118
|
-
if custom_header is None:
|
119
|
-
authorization = request.headers.get("Authorization")
|
120
|
-
if authorization is None:
|
121
|
-
return make_response("Not authenticated", 401, {"WWW-Authenticate": "ApiKey"})
|
122
|
-
scheme, _, api_key = authorization.partition(" ")
|
123
|
-
if scheme.lower() != "apikey":
|
124
|
-
return make_response("Unsupported authentication scheme", 401, {"WWW-Authenticate": "ApiKey"})
|
125
|
-
else:
|
126
|
-
api_key = request.headers.get(custom_header)
|
127
|
-
if api_key is None:
|
128
|
-
return make_response("Missing API key", 403)
|
129
|
-
key_info = ApitallyClient.get_instance().key_registry.get(api_key)
|
130
|
-
if key_info is None:
|
131
|
-
return make_response("Invalid API key", 403)
|
132
|
-
if scopes is not None and not key_info.has_scopes(scopes):
|
133
|
-
return make_response("Permission denied", 403)
|
134
|
-
g.key_info = key_info
|
135
|
-
return func(*args, **kwargs)
|
136
|
-
|
137
|
-
return wrapped_func
|
138
|
-
|
139
|
-
return decorator if func is None else decorator(func)
|
97
|
+
return str(g.consumer_identifier) if "consumer_identifier" in g else None
|
140
98
|
|
141
99
|
|
142
100
|
def _get_app_info(app: Flask, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> Dict[str, Any]:
|
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
|
apitally/starlette.py
CHANGED
@@ -5,28 +5,11 @@ import json
|
|
5
5
|
import sys
|
6
6
|
import time
|
7
7
|
from importlib.metadata import PackageNotFoundError, version
|
8
|
-
from typing import
|
9
|
-
TYPE_CHECKING,
|
10
|
-
Any,
|
11
|
-
Callable,
|
12
|
-
Dict,
|
13
|
-
List,
|
14
|
-
Optional,
|
15
|
-
Tuple,
|
16
|
-
Type,
|
17
|
-
Union,
|
18
|
-
)
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union
|
19
9
|
|
20
10
|
from httpx import HTTPStatusError
|
21
|
-
from starlette.authentication import (
|
22
|
-
AuthCredentials,
|
23
|
-
AuthenticationBackend,
|
24
|
-
AuthenticationError,
|
25
|
-
BaseUser,
|
26
|
-
)
|
27
11
|
from starlette.concurrency import iterate_in_threadpool
|
28
12
|
from starlette.middleware.base import BaseHTTPMiddleware
|
29
|
-
from starlette.requests import HTTPConnection
|
30
13
|
from starlette.routing import BaseRoute, Match, Router
|
31
14
|
from starlette.schemas import EndpointInfo, SchemaGenerator
|
32
15
|
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
|
@@ -34,7 +17,6 @@ from starlette.testclient import TestClient
|
|
34
17
|
from starlette.types import ASGIApp
|
35
18
|
|
36
19
|
from apitally.client.asyncio import ApitallyClient
|
37
|
-
from apitally.client.base import ApitallyKeyCacheBase, KeyInfo
|
38
20
|
|
39
21
|
|
40
22
|
if TYPE_CHECKING:
|
@@ -43,7 +25,7 @@ if TYPE_CHECKING:
|
|
43
25
|
from starlette.responses import Response
|
44
26
|
|
45
27
|
|
46
|
-
__all__ = ["ApitallyMiddleware"
|
28
|
+
__all__ = ["ApitallyMiddleware"]
|
47
29
|
|
48
30
|
|
49
31
|
class ApitallyMiddleware(BaseHTTPMiddleware):
|
@@ -53,20 +35,13 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
53
35
|
client_id: str,
|
54
36
|
env: str = "dev",
|
55
37
|
app_version: Optional[str] = None,
|
56
|
-
sync_api_keys: bool = False,
|
57
38
|
openapi_url: Optional[str] = "/openapi.json",
|
58
39
|
filter_unhandled_paths: bool = True,
|
59
40
|
identify_consumer_callback: Optional[Callable[[Request], Optional[str]]] = None,
|
60
|
-
key_cache_class: Optional[Type[ApitallyKeyCacheBase]] = None,
|
61
41
|
) -> None:
|
62
42
|
self.filter_unhandled_paths = filter_unhandled_paths
|
63
43
|
self.identify_consumer_callback = identify_consumer_callback
|
64
|
-
self.client = ApitallyClient(
|
65
|
-
client_id=client_id,
|
66
|
-
env=env,
|
67
|
-
sync_api_keys=sync_api_keys,
|
68
|
-
key_cache_class=key_cache_class,
|
69
|
-
)
|
44
|
+
self.client = ApitallyClient(client_id=client_id, env=env)
|
70
45
|
self.client.start_sync_loop()
|
71
46
|
self.delayed_set_app_info(app_version, openapi_url)
|
72
47
|
_register_shutdown_handler(app, self.client.handle_shutdown)
|
@@ -161,52 +136,9 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
161
136
|
consumer_identifier = self.identify_consumer_callback(request)
|
162
137
|
if consumer_identifier is not None:
|
163
138
|
return str(consumer_identifier)
|
164
|
-
if hasattr(request.state, "key_info") and isinstance(key_info := request.state.key_info, KeyInfo):
|
165
|
-
return f"key:{key_info.key_id}"
|
166
|
-
if "user" in request.scope and isinstance(user := request.scope["user"], APIKeyUser):
|
167
|
-
return f"key:{user.key_info.key_id}"
|
168
139
|
return None
|
169
140
|
|
170
141
|
|
171
|
-
class APIKeyAuth(AuthenticationBackend):
|
172
|
-
def __init__(self, custom_header: Optional[str] = None) -> None:
|
173
|
-
self.custom_header = custom_header
|
174
|
-
|
175
|
-
async def authenticate(self, conn: HTTPConnection) -> Optional[Tuple[AuthCredentials, BaseUser]]:
|
176
|
-
if self.custom_header is None:
|
177
|
-
if "Authorization" not in conn.headers:
|
178
|
-
return None
|
179
|
-
auth = conn.headers["Authorization"]
|
180
|
-
scheme, _, api_key = auth.partition(" ")
|
181
|
-
if scheme.lower() != "apikey":
|
182
|
-
return None
|
183
|
-
elif self.custom_header not in conn.headers:
|
184
|
-
return None
|
185
|
-
else:
|
186
|
-
api_key = conn.headers[self.custom_header]
|
187
|
-
key_info = ApitallyClient.get_instance().key_registry.get(api_key)
|
188
|
-
if key_info is None:
|
189
|
-
raise AuthenticationError("Invalid API key")
|
190
|
-
return AuthCredentials(["authenticated"] + key_info.scopes), APIKeyUser(key_info)
|
191
|
-
|
192
|
-
|
193
|
-
class APIKeyUser(BaseUser):
|
194
|
-
def __init__(self, key_info: KeyInfo) -> None:
|
195
|
-
self.key_info = key_info
|
196
|
-
|
197
|
-
@property
|
198
|
-
def is_authenticated(self) -> bool:
|
199
|
-
return True
|
200
|
-
|
201
|
-
@property
|
202
|
-
def display_name(self) -> str:
|
203
|
-
return self.key_info.name
|
204
|
-
|
205
|
-
@property
|
206
|
-
def identity(self) -> str:
|
207
|
-
return str(self.key_info.key_id)
|
208
|
-
|
209
|
-
|
210
142
|
def _get_app_info(app: ASGIApp, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> Dict[str, Any]:
|
211
143
|
app_info: Dict[str, Any] = {}
|
212
144
|
if openapi_url and (openapi := _get_openapi(app, openapi_url)):
|
@@ -1,8 +1,8 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: apitally
|
3
|
-
Version: 0.
|
4
|
-
Summary:
|
5
|
-
Home-page: https://
|
3
|
+
Version: 0.6.0
|
4
|
+
Summary: API monitoring for REST APIs built with FastAPI, Flask, Django, and Starlette.
|
5
|
+
Home-page: https://apitally.io
|
6
6
|
License: MIT
|
7
7
|
Author: Apitally
|
8
8
|
Author-email: hello@apitally.io
|
@@ -21,19 +21,22 @@ 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
|
24
25
|
Provides-Extra: django-ninja
|
25
26
|
Provides-Extra: django-rest-framework
|
26
27
|
Provides-Extra: fastapi
|
27
28
|
Provides-Extra: flask
|
29
|
+
Provides-Extra: litestar
|
28
30
|
Provides-Extra: starlette
|
29
31
|
Requires-Dist: backoff (>=2.0.0)
|
30
|
-
Requires-Dist: django (>=4.0) ; extra == "django
|
32
|
+
Requires-Dist: django (>=4.0) ; extra == "django" or extra == "django-ninja" or extra == "django-rest-framework"
|
31
33
|
Requires-Dist: django-ninja (>=0.18.0) ; extra == "django-ninja"
|
32
34
|
Requires-Dist: djangorestframework (>=3.12.0) ; extra == "django-rest-framework"
|
33
35
|
Requires-Dist: fastapi (>=0.87.0) ; extra == "fastapi"
|
34
36
|
Requires-Dist: flask (>=2.0.0) ; extra == "flask"
|
35
|
-
Requires-Dist: httpx (>=0.22.0) ; extra == "fastapi" or extra == "starlette"
|
36
|
-
Requires-Dist:
|
37
|
+
Requires-Dist: httpx (>=0.22.0) ; extra == "fastapi" or extra == "litestar" or extra == "starlette"
|
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"
|
37
40
|
Requires-Dist: starlette (>=0.21.0,<1.0.0) ; extra == "fastapi" or extra == "starlette"
|
38
41
|
Project-URL: Documentation, https://docs.apitally.io
|
39
42
|
Project-URL: Repository, https://github.com/apitally/python-client
|
@@ -47,9 +50,9 @@ Description-Content-Type: text/markdown
|
|
47
50
|
</picture>
|
48
51
|
</p>
|
49
52
|
|
50
|
-
<p align="center"><b>
|
53
|
+
<p align="center"><b>API monitoring made easy.</b></p>
|
51
54
|
|
52
|
-
<p align="center"><i>Apitally is a simple and affordable API monitoring
|
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>
|
53
56
|
|
54
57
|
<p align="center">🔗 <b><a href="https://apitally.io" target="_blank">apitally.io</a></b></p>
|
55
58
|
|
@@ -71,6 +74,7 @@ frameworks:
|
|
71
74
|
- [Flask](https://docs.apitally.io/frameworks/flask)
|
72
75
|
- [Django Ninja](https://docs.apitally.io/frameworks/django-ninja)
|
73
76
|
- [Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework)
|
77
|
+
- [Litestar](https://docs.apitally.io/frameworks/litestar)
|
74
78
|
|
75
79
|
Learn more about Apitally on our 🌎 [website](https://apitally.io) or check out
|
76
80
|
the 📚 [documentation](https://docs.apitally.io).
|
@@ -79,10 +83,8 @@ the 📚 [documentation](https://docs.apitally.io).
|
|
79
83
|
|
80
84
|
- Middleware for different frameworks to capture metadata about API endpoints,
|
81
85
|
requests and responses (no sensitive data is captured)
|
82
|
-
- Non-blocking clients that aggregate and send captured data to Apitally
|
83
|
-
|
84
|
-
- Functions to easily secure endpoints with API key authentication and
|
85
|
-
permission checks
|
86
|
+
- Non-blocking clients that aggregate and send captured data to Apitally in
|
87
|
+
regular intervals
|
86
88
|
|
87
89
|
## Install
|
88
90
|
|
@@ -93,8 +95,8 @@ example:
|
|
93
95
|
pip install apitally[fastapi]
|
94
96
|
```
|
95
97
|
|
96
|
-
The available extras are: `fastapi`, `starlette`, `flask`, `
|
97
|
-
`
|
98
|
+
The available extras are: `fastapi`, `starlette`, `flask`, `django` and
|
99
|
+
`litestar`.
|
98
100
|
|
99
101
|
## Usage
|
100
102
|
|
@@ -119,24 +121,6 @@ app.add_middleware(
|
|
119
121
|
)
|
120
122
|
```
|
121
123
|
|
122
|
-
### Starlette
|
123
|
-
|
124
|
-
This is an example of how to add the Apitally middleware to a Starlette
|
125
|
-
application. For further instructions, see our
|
126
|
-
[setup guide for Starlette](https://docs.apitally.io/frameworks/starlette).
|
127
|
-
|
128
|
-
```python
|
129
|
-
from starlette.applications import Starlette
|
130
|
-
from apitally.starlette import ApitallyMiddleware
|
131
|
-
|
132
|
-
app = Starlette(routes=[...])
|
133
|
-
app.add_middleware(
|
134
|
-
ApitallyMiddleware,
|
135
|
-
client_id="your-client-id",
|
136
|
-
env="dev", # or "prod" etc.
|
137
|
-
)
|
138
|
-
```
|
139
|
-
|
140
124
|
### Flask
|
141
125
|
|
142
126
|
This is an example of how to add the Apitally middleware to a Flask application.
|
@@ -155,17 +139,17 @@ app.wsgi_app = ApitallyMiddleware(
|
|
155
139
|
)
|
156
140
|
```
|
157
141
|
|
158
|
-
### Django
|
142
|
+
### Django
|
159
143
|
|
160
|
-
This is an example of how to add the Apitally middleware to a Django Ninja
|
161
|
-
application. For further instructions, see our
|
162
|
-
[setup guide for Django
|
144
|
+
This is an example of how to add the Apitally middleware to a Django Ninja or
|
145
|
+
Django REST Framework application. For further instructions, see our
|
146
|
+
[setup guide for Django](https://docs.apitally.io/frameworks/django).
|
163
147
|
|
164
148
|
In your Django `settings.py` file:
|
165
149
|
|
166
150
|
```python
|
167
151
|
MIDDLEWARE = [
|
168
|
-
"apitally.
|
152
|
+
"apitally.django.ApitallyMiddleware",
|
169
153
|
# Other middleware ...
|
170
154
|
]
|
171
155
|
APITALLY_MIDDLEWARE = {
|
@@ -174,30 +158,33 @@ APITALLY_MIDDLEWARE = {
|
|
174
158
|
}
|
175
159
|
```
|
176
160
|
|
177
|
-
###
|
178
|
-
|
179
|
-
This is an example of how to add the Apitally middleware to a Django REST
|
180
|
-
Framework application. For further instructions, see our
|
181
|
-
[setup guide for Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework).
|
161
|
+
### Litestar
|
182
162
|
|
183
|
-
|
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).
|
184
166
|
|
185
167
|
```python
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
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
|
+
)
|
194
180
|
```
|
195
181
|
|
196
182
|
## Getting help
|
197
183
|
|
198
184
|
If you need help please
|
199
185
|
[create a new discussion](https://github.com/orgs/apitally/discussions/categories/q-a)
|
200
|
-
on GitHub
|
186
|
+
on GitHub or
|
187
|
+
[join our Slack workspace](https://join.slack.com/t/apitally-community/shared_invite/zt-2b3xxqhdu-9RMq2HyZbR79wtzNLoGHrg).
|
201
188
|
|
202
189
|
## License
|
203
190
|
|
@@ -0,0 +1,18 @@
|
|
1
|
+
apitally/__init__.py,sha256=cID1jLnC_vj48GgMN6Yb1FA3JsQ95zNmCHmRYE8TFhY,22
|
2
|
+
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
+
apitally/client/asyncio.py,sha256=uR5JlH37G6gZvAJ7A1gYOGkjn3zjC-4I6avA1fncXHs,4433
|
4
|
+
apitally/client/base.py,sha256=g-v7_S-ie2DN2HCVtgTlImZGTMXVU9CrKraMZiyM6Ds,8353
|
5
|
+
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
6
|
+
apitally/client/threading.py,sha256=ihQzUStrSQFynpqXgFpseAXrHuc5Et1QvG-YHlzqDr8,4831
|
7
|
+
apitally/django.py,sha256=vL2vBelXis-9d3CDWjKzLsOjdSbBDnZX0v1oDtBvtNM,9943
|
8
|
+
apitally/django_ninja.py,sha256=iMvZd7j04nbOLpJgYxs7tpbsyXlZuhmHjcswXMvyUlU,82
|
9
|
+
apitally/django_rest_framework.py,sha256=iMvZd7j04nbOLpJgYxs7tpbsyXlZuhmHjcswXMvyUlU,82
|
10
|
+
apitally/fastapi.py,sha256=Q3n2bVREKQ_V_2yCQ48ngPtr-NJxDskpT_l20xhSbpM,85
|
11
|
+
apitally/flask.py,sha256=-R0MP72ufO3v0p30JBU9asODWtuOU3FOCl5iY-kTSzw,5099
|
12
|
+
apitally/litestar.py,sha256=VgKhbKRjVHVjzdw7PgMxedaOrSB6_ZUo9L4XNr3iRxc,7437
|
13
|
+
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
14
|
+
apitally/starlette.py,sha256=VlLWOR9rzNlI9IjFbQ86APaWanURerIt52tX6bRGzCo,7814
|
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,,
|
apitally-0.4.1.dist-info/RECORD
DELETED
@@ -1,17 +0,0 @@
|
|
1
|
-
apitally/__init__.py,sha256=pMtTmSUht-XtbR_7Doz6bsQqopJJd8rZ8I8zy2HwwoA,22
|
2
|
-
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
-
apitally/client/asyncio.py,sha256=A7FEk-VD7YY2UPVBNaI8Roh3e0YLAVpD5iU6wS5067I,5977
|
4
|
-
apitally/client/base.py,sha256=iEBFWId-M6_V1roAE4z270iwdZQJ48NZ7trAOSEAUBk,12489
|
5
|
-
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
6
|
-
apitally/client/threading.py,sha256=CALCE4AGMMedhR5GpwiPqY4BZIiIu3TrIa-H2dzpxvg,6396
|
7
|
-
apitally/django.py,sha256=i2mb7sHes6Q4JE1kdo1BvDBBcGZzuv946e4MDlAE62c,10555
|
8
|
-
apitally/django_ninja.py,sha256=TFltgr03FzTnl83sUAXJj7R32u_g9DTZ9p-HuVKs4ZE,2785
|
9
|
-
apitally/django_rest_framework.py,sha256=UmJvxxiKGRdaILSbg6jJY_cvAl-mpuPY1pM0FoQ4bg0,1587
|
10
|
-
apitally/fastapi.py,sha256=YjnrRis8UG2M6Q3lkwizbtDXU7nPfCA4mebxG8XwveY,3334
|
11
|
-
apitally/flask.py,sha256=C3_Uk0XiVyayXURnzKvoG7EAFthA6PeV7mLys22BiO4,6960
|
12
|
-
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
|
-
apitally/starlette.py,sha256=8PQdvw0h0_rBVziPciMGtHf0vxJ_mlrKOSyFAB7Mr14,9941
|
14
|
-
apitally-0.4.1.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
15
|
-
apitally-0.4.1.dist-info/METADATA,sha256=J-oAJmEw8L92xjTx3rCZhz7xvZWn4QW5PG9GL9eHhpY,6983
|
16
|
-
apitally-0.4.1.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
|
17
|
-
apitally-0.4.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|