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.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: apitally
3
- Version: 0.4.1
4
- Summary: Simple API monitoring and API key management for REST APIs built with FastAPI, Flask, Django, and Starlette.
5
- Home-page: https://docs.apitally.io
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-rest-framework" or extra == "django-ninja"
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: requests (>=2.26.0) ; extra == "django-rest-framework" or extra == "django-ninja" or extra == "flask"
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>Your refreshingly simple REST API companion.</b></p>
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 and API key management 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>
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 and
83
- optionally synchronize API key hashes in 1 minute intervals
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`, `django_ninja` and
97
- `django_rest_framework`.
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 Ninja
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 Ninja](https://docs.apitally.io/frameworks/django-ninja).
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.django_ninja.ApitallyMiddleware",
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
- ### Django REST Framework
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
- In your Django `settings.py` file:
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
- MIDDLEWARE = [
187
- "apitally.django_rest_framework.ApitallyMiddleware",
188
- # Other middleware ...
189
- ]
190
- APITALLY_MIDDLEWARE = {
191
- "client_id": "your-client-id",
192
- "env": "dev", # or "prod" etc.
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>Your refreshingly simple REST API companion.</b></p>
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 and API key management 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 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 and
42
- optionally synchronize API key hashes in 1 minute intervals
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`, `django_ninja` and
56
- `django_rest_framework`.
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 Ninja
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 Ninja](https://docs.apitally.io/frameworks/django-ninja).
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.django_ninja.ApitallyMiddleware",
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
- ### Django REST Framework
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
- In your Django `settings.py` file:
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
- MIDDLEWARE = [
146
- "apitally.django_rest_framework.ApitallyMiddleware",
147
- # Other middleware ...
148
- ]
149
- APITALLY_MIDDLEWARE = {
150
- "client_id": "your-client-id",
151
- "env": "dev", # or "prod" etc.
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, Type
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
- self,
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 and "Client ID" in response.text:
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, abstractmethod
7
+ from abc import ABC
9
8
  from collections import Counter
10
- from dataclasses import dataclass, field
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, Union, cast
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) -> ApitallyClientBase:
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