apitally 0.4.1__tar.gz → 0.6.0__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.
- {apitally-0.4.1 → apitally-0.6.0}/PKG-INFO +39 -52
- {apitally-0.4.1 → apitally-0.6.0}/README.md +30 -46
- apitally-0.6.0/apitally/__init__.py +1 -0
- {apitally-0.4.1 → apitally-0.6.0}/apitally/client/asyncio.py +5 -44
- {apitally-0.4.1 → apitally-0.6.0}/apitally/client/base.py +8 -122
- {apitally-0.4.1 → apitally-0.6.0}/apitally/client/threading.py +5 -44
- {apitally-0.4.1 → apitally-0.6.0}/apitally/django.py +2 -16
- apitally-0.6.0/apitally/django_ninja.py +4 -0
- apitally-0.6.0/apitally/django_rest_framework.py +4 -0
- apitally-0.6.0/apitally/fastapi.py +4 -0
- {apitally-0.4.1 → apitally-0.6.0}/apitally/flask.py +5 -47
- apitally-0.6.0/apitally/litestar.py +186 -0
- {apitally-0.4.1 → apitally-0.6.0}/apitally/starlette.py +3 -71
- {apitally-0.4.1 → apitally-0.6.0}/pyproject.toml +9 -5
- apitally-0.4.1/apitally/__init__.py +0 -1
- apitally-0.4.1/apitally/django_ninja.py +0 -84
- apitally-0.4.1/apitally/django_rest_framework.py +0 -48
- apitally-0.4.1/apitally/fastapi.py +0 -77
- {apitally-0.4.1 → apitally-0.6.0}/LICENSE +0 -0
- {apitally-0.4.1 → apitally-0.6.0}/apitally/client/__init__.py +0 -0
- {apitally-0.4.1 → apitally-0.6.0}/apitally/client/logging.py +0 -0
- {apitally-0.4.1 → apitally-0.6.0}/apitally/py.typed +0 -0
@@ -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
|
|
@@ -6,9 +6,9 @@
|
|
6
6
|
</picture>
|
7
7
|
</p>
|
8
8
|
|
9
|
-
<p align="center"><b>
|
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
|
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>
|
12
12
|
|
13
13
|
<p align="center">🔗 <b><a href="https://apitally.io" target="_blank">apitally.io</a></b></p>
|
14
14
|
|
@@ -30,6 +30,7 @@ frameworks:
|
|
30
30
|
- [Flask](https://docs.apitally.io/frameworks/flask)
|
31
31
|
- [Django Ninja](https://docs.apitally.io/frameworks/django-ninja)
|
32
32
|
- [Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework)
|
33
|
+
- [Litestar](https://docs.apitally.io/frameworks/litestar)
|
33
34
|
|
34
35
|
Learn more about Apitally on our 🌎 [website](https://apitally.io) or check out
|
35
36
|
the 📚 [documentation](https://docs.apitally.io).
|
@@ -38,10 +39,8 @@ the 📚 [documentation](https://docs.apitally.io).
|
|
38
39
|
|
39
40
|
- Middleware for different frameworks to capture metadata about API endpoints,
|
40
41
|
requests and responses (no sensitive data is captured)
|
41
|
-
- Non-blocking clients that aggregate and send captured data to Apitally
|
42
|
-
|
43
|
-
- Functions to easily secure endpoints with API key authentication and
|
44
|
-
permission checks
|
42
|
+
- Non-blocking clients that aggregate and send captured data to Apitally in
|
43
|
+
regular intervals
|
45
44
|
|
46
45
|
## Install
|
47
46
|
|
@@ -52,8 +51,8 @@ example:
|
|
52
51
|
pip install apitally[fastapi]
|
53
52
|
```
|
54
53
|
|
55
|
-
The available extras are: `fastapi`, `starlette`, `flask`, `
|
56
|
-
`
|
54
|
+
The available extras are: `fastapi`, `starlette`, `flask`, `django` and
|
55
|
+
`litestar`.
|
57
56
|
|
58
57
|
## Usage
|
59
58
|
|
@@ -78,24 +77,6 @@ app.add_middleware(
|
|
78
77
|
)
|
79
78
|
```
|
80
79
|
|
81
|
-
### Starlette
|
82
|
-
|
83
|
-
This is an example of how to add the Apitally middleware to a Starlette
|
84
|
-
application. For further instructions, see our
|
85
|
-
[setup guide for Starlette](https://docs.apitally.io/frameworks/starlette).
|
86
|
-
|
87
|
-
```python
|
88
|
-
from starlette.applications import Starlette
|
89
|
-
from apitally.starlette import ApitallyMiddleware
|
90
|
-
|
91
|
-
app = Starlette(routes=[...])
|
92
|
-
app.add_middleware(
|
93
|
-
ApitallyMiddleware,
|
94
|
-
client_id="your-client-id",
|
95
|
-
env="dev", # or "prod" etc.
|
96
|
-
)
|
97
|
-
```
|
98
|
-
|
99
80
|
### Flask
|
100
81
|
|
101
82
|
This is an example of how to add the Apitally middleware to a Flask application.
|
@@ -114,17 +95,17 @@ app.wsgi_app = ApitallyMiddleware(
|
|
114
95
|
)
|
115
96
|
```
|
116
97
|
|
117
|
-
### Django
|
98
|
+
### Django
|
118
99
|
|
119
|
-
This is an example of how to add the Apitally middleware to a Django Ninja
|
120
|
-
application. For further instructions, see our
|
121
|
-
[setup guide for Django
|
100
|
+
This is an example of how to add the Apitally middleware to a Django Ninja or
|
101
|
+
Django REST Framework application. For further instructions, see our
|
102
|
+
[setup guide for Django](https://docs.apitally.io/frameworks/django).
|
122
103
|
|
123
104
|
In your Django `settings.py` file:
|
124
105
|
|
125
106
|
```python
|
126
107
|
MIDDLEWARE = [
|
127
|
-
"apitally.
|
108
|
+
"apitally.django.ApitallyMiddleware",
|
128
109
|
# Other middleware ...
|
129
110
|
]
|
130
111
|
APITALLY_MIDDLEWARE = {
|
@@ -133,30 +114,33 @@ APITALLY_MIDDLEWARE = {
|
|
133
114
|
}
|
134
115
|
```
|
135
116
|
|
136
|
-
###
|
137
|
-
|
138
|
-
This is an example of how to add the Apitally middleware to a Django REST
|
139
|
-
Framework application. For further instructions, see our
|
140
|
-
[setup guide for Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework).
|
117
|
+
### Litestar
|
141
118
|
|
142
|
-
|
119
|
+
This is an example of how to add the Apitally plugin to a Litestar application.
|
120
|
+
For further instructions, see our
|
121
|
+
[setup guide for Litestar](https://docs.apitally.io/frameworks/litestar).
|
143
122
|
|
144
123
|
```python
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
124
|
+
from litestar import Litestar
|
125
|
+
from apitally.litestar import ApitallyPlugin
|
126
|
+
|
127
|
+
app = Litestar(
|
128
|
+
route_handlers=[...],
|
129
|
+
plugins=[
|
130
|
+
ApitallyPlugin(
|
131
|
+
client_id="your-client-id",
|
132
|
+
env="dev", # or "prod" etc.
|
133
|
+
),
|
134
|
+
]
|
135
|
+
)
|
153
136
|
```
|
154
137
|
|
155
138
|
## Getting help
|
156
139
|
|
157
140
|
If you need help please
|
158
141
|
[create a new discussion](https://github.com/orgs/apitally/discussions/categories/q-a)
|
159
|
-
on GitHub
|
142
|
+
on GitHub or
|
143
|
+
[join our Slack workspace](https://join.slack.com/t/apitally-community/shared_invite/zt-2b3xxqhdu-9RMq2HyZbR79wtzNLoGHrg).
|
160
144
|
|
161
145
|
## License
|
162
146
|
|
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = "0.6.0"
|
@@ -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()
|
@@ -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
|