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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.4.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()
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, 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
@@ -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, Type
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
- self,
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 and "Client ID" in response.text:
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, Type
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 __future__ import annotations
1
+ from apitally.django import ApitallyMiddleware
2
2
 
3
- from typing import TYPE_CHECKING, Callable, List, Optional
4
3
 
5
- from ninja.security import APIKeyHeader
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
- if TYPE_CHECKING:
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", "APIKeyAuth", "KeyInfo", "api_key_auth"]
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, Type
7
+ from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
9
8
 
10
- from flask import Flask, g, make_response, request
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", "require_api_key"]
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", "APIKeyAuth"]
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.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
 
@@ -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,,
@@ -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,,