fast-cache-middleware 0.0.1__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.
- fast_cache_middleware/__init__.py +34 -0
- fast_cache_middleware/controller.py +231 -0
- fast_cache_middleware/depends.py +66 -0
- fast_cache_middleware/exceptions.py +6 -0
- fast_cache_middleware/middleware.py +293 -0
- fast_cache_middleware/schemas.py +21 -0
- fast_cache_middleware/serializers.py +92 -0
- fast_cache_middleware/storages.py +238 -0
- fast_cache_middleware-0.0.1.dist-info/METADATA +370 -0
- fast_cache_middleware-0.0.1.dist-info/RECORD +11 -0
- fast_cache_middleware-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,34 @@
|
|
1
|
+
"""FastCacheMiddleware - high-performance ASGI middleware for caching.
|
2
|
+
|
3
|
+
Route resolution approach:
|
4
|
+
- Route analysis at application startup
|
5
|
+
- Cache configuration extraction from FastAPI dependencies
|
6
|
+
- Efficient caching and invalidation based on routes
|
7
|
+
|
8
|
+
|
9
|
+
TODO:
|
10
|
+
- add check for dependencies for middleware exists. and raise error if not.
|
11
|
+
"""
|
12
|
+
|
13
|
+
from .controller import Controller
|
14
|
+
from .depends import BaseCacheConfigDepends, CacheConfig, CacheDropConfig
|
15
|
+
from .middleware import FastCacheMiddleware
|
16
|
+
from .storages import BaseStorage, InMemoryStorage
|
17
|
+
|
18
|
+
__version__ = "1.0.0"
|
19
|
+
|
20
|
+
__all__ = [
|
21
|
+
# Main components
|
22
|
+
"FastCacheMiddleware",
|
23
|
+
"Controller",
|
24
|
+
# Configuration via dependencies
|
25
|
+
"CacheConfig",
|
26
|
+
"CacheDropConfig",
|
27
|
+
"BaseCacheConfigDepends",
|
28
|
+
# Storages
|
29
|
+
"BaseStorage",
|
30
|
+
"InMemoryStorage",
|
31
|
+
# Serialization
|
32
|
+
"BaseSerializer",
|
33
|
+
"DefaultSerializer",
|
34
|
+
]
|
@@ -0,0 +1,231 @@
|
|
1
|
+
import http
|
2
|
+
import logging
|
3
|
+
import typing as tp
|
4
|
+
from hashlib import blake2b
|
5
|
+
|
6
|
+
from starlette.requests import Request
|
7
|
+
from starlette.responses import Response
|
8
|
+
|
9
|
+
from .depends import CacheConfig, CacheDropConfig
|
10
|
+
from .storages import BaseStorage
|
11
|
+
|
12
|
+
logger = logging.getLogger(__name__)
|
13
|
+
|
14
|
+
KNOWN_HTTP_METHODS = [method.value for method in http.HTTPMethod]
|
15
|
+
|
16
|
+
|
17
|
+
def generate_key(request: Request) -> str:
|
18
|
+
"""Generates fast unique key for caching HTTP request.
|
19
|
+
|
20
|
+
Args:
|
21
|
+
request: Starlette Request object.
|
22
|
+
|
23
|
+
Returns:
|
24
|
+
str: Unique key for caching, based on request method and path.
|
25
|
+
Uses fast blake2b hashing algorithm.
|
26
|
+
|
27
|
+
Note:
|
28
|
+
Does not consider scheme and host, as requests usually go to the same host.
|
29
|
+
Only considers method, path and query parameters for maximum performance.
|
30
|
+
"""
|
31
|
+
# Get only necessary components from scope
|
32
|
+
scope = request.scope
|
33
|
+
url = scope["path"]
|
34
|
+
if scope["query_string"]:
|
35
|
+
url += f"?{scope['query_string'].decode('ascii')}"
|
36
|
+
|
37
|
+
# Use fast blake2b algorithm with minimal digest size
|
38
|
+
key = blake2b(digest_size=8)
|
39
|
+
key.update(request.method.encode())
|
40
|
+
key.update(url.encode())
|
41
|
+
|
42
|
+
return key.hexdigest()
|
43
|
+
|
44
|
+
|
45
|
+
class Controller:
|
46
|
+
"""Caching controller for Starlette/FastAPI.
|
47
|
+
|
48
|
+
Responsibilities:
|
49
|
+
1. Define rules for caching requests and responses
|
50
|
+
2. Generate cache keys with custom functions
|
51
|
+
3. Manage TTL and validation of cached data
|
52
|
+
4. Check HTTP caching headers
|
53
|
+
5. Invalidate cache by URL patterns
|
54
|
+
|
55
|
+
Supports:
|
56
|
+
- Custom key generation functions via CacheConfig
|
57
|
+
- Cache invalidation by URL patterns via CacheDropConfig
|
58
|
+
- Standard HTTP caching headers (Cache-Control, ETag, Last-Modified)
|
59
|
+
- Cache lifetime configuration via max_age in CacheConfig
|
60
|
+
"""
|
61
|
+
|
62
|
+
def __init__(
|
63
|
+
self,
|
64
|
+
cacheable_methods: list[str] | None = None,
|
65
|
+
cacheable_status_codes: list[int] | None = None,
|
66
|
+
) -> None:
|
67
|
+
self.cacheable_methods = []
|
68
|
+
if cacheable_methods:
|
69
|
+
for method in cacheable_methods:
|
70
|
+
method = method.upper()
|
71
|
+
if method in KNOWN_HTTP_METHODS:
|
72
|
+
self.cacheable_methods.append(method)
|
73
|
+
else:
|
74
|
+
raise ValueError(f"Invalid HTTP method: {method}")
|
75
|
+
else:
|
76
|
+
self.cacheable_methods.append(http.HTTPMethod.GET.value)
|
77
|
+
|
78
|
+
self.cacheable_status_codes = cacheable_status_codes or [
|
79
|
+
http.HTTPStatus.OK.value,
|
80
|
+
http.HTTPStatus.MOVED_PERMANENTLY.value,
|
81
|
+
http.HTTPStatus.PERMANENT_REDIRECT.value,
|
82
|
+
]
|
83
|
+
|
84
|
+
async def is_cachable_request(self, request: Request) -> bool:
|
85
|
+
"""Determines if this request should be cached.
|
86
|
+
|
87
|
+
Args:
|
88
|
+
request: HTTP request
|
89
|
+
cache_config: Cache configuration
|
90
|
+
|
91
|
+
Returns:
|
92
|
+
bool: True if request should be cached
|
93
|
+
"""
|
94
|
+
# Cache only GET requests by default
|
95
|
+
if request.method not in self.cacheable_methods:
|
96
|
+
return False
|
97
|
+
|
98
|
+
# Check Cache-Control headers
|
99
|
+
# todo: add parsing cache-control function
|
100
|
+
cache_control = request.headers.get("cache-control", "").lower()
|
101
|
+
if "no-cache" in cache_control or "no-store" in cache_control:
|
102
|
+
return False
|
103
|
+
|
104
|
+
return True
|
105
|
+
|
106
|
+
async def is_cachable_response(self, response: Response) -> bool:
|
107
|
+
"""Determines if this response can be cached.
|
108
|
+
|
109
|
+
Args:
|
110
|
+
request: HTTP request
|
111
|
+
response: HTTP response
|
112
|
+
|
113
|
+
Returns:
|
114
|
+
bool: True if response can be cached
|
115
|
+
"""
|
116
|
+
if response.status_code not in self.cacheable_status_codes:
|
117
|
+
return False
|
118
|
+
|
119
|
+
# Check Cache-Control headers
|
120
|
+
cache_control = response.headers.get("cache-control", "").lower()
|
121
|
+
if (
|
122
|
+
"no-cache" in cache_control
|
123
|
+
or "no-store" in cache_control
|
124
|
+
or "private" in cache_control
|
125
|
+
):
|
126
|
+
return False
|
127
|
+
|
128
|
+
# Check response size (don't cache too large responses)
|
129
|
+
if (
|
130
|
+
hasattr(response, "body")
|
131
|
+
and response.body
|
132
|
+
and len(response.body) > 1024 * 1024
|
133
|
+
): # 1MB
|
134
|
+
return False
|
135
|
+
|
136
|
+
return True
|
137
|
+
|
138
|
+
async def generate_cache_key(
|
139
|
+
self, request: Request, cache_config: CacheConfig
|
140
|
+
) -> str:
|
141
|
+
"""Generates cache key for request.
|
142
|
+
|
143
|
+
Args:
|
144
|
+
request: HTTP request
|
145
|
+
cache_config: Cache configuration
|
146
|
+
|
147
|
+
Returns:
|
148
|
+
str: Cache key
|
149
|
+
"""
|
150
|
+
# Use custom key generation function if available
|
151
|
+
if cache_config.key_func:
|
152
|
+
return cache_config.key_func(request)
|
153
|
+
|
154
|
+
# Use standard function
|
155
|
+
return generate_key(request)
|
156
|
+
|
157
|
+
async def cache_response(
|
158
|
+
self,
|
159
|
+
cache_key: str,
|
160
|
+
request: Request,
|
161
|
+
response: Response,
|
162
|
+
storage: BaseStorage,
|
163
|
+
ttl: tp.Optional[int] = None,
|
164
|
+
) -> None:
|
165
|
+
"""Saves response to cache.
|
166
|
+
|
167
|
+
Args:
|
168
|
+
cache_key: Cache key
|
169
|
+
request: HTTP request
|
170
|
+
response: HTTP response to cache
|
171
|
+
storage: Cache storage
|
172
|
+
ttl: Cache lifetime in seconds
|
173
|
+
todo: in meta can write etag and last_modified from response headers
|
174
|
+
"""
|
175
|
+
if await self.is_cachable_response(response):
|
176
|
+
response.headers["X-Cache-Status"] = "HIT"
|
177
|
+
await storage.store(cache_key, response, request, {"ttl": ttl})
|
178
|
+
else:
|
179
|
+
logger.debug("Skip caching for response: %s", response.status_code)
|
180
|
+
|
181
|
+
async def get_cached_response(
|
182
|
+
self, cache_key: str, storage: BaseStorage
|
183
|
+
) -> tp.Optional[Response]:
|
184
|
+
"""Gets cached response if it exists and is valid.
|
185
|
+
|
186
|
+
Args:
|
187
|
+
cache_key: Cache key
|
188
|
+
storage: Cache storage
|
189
|
+
|
190
|
+
Returns:
|
191
|
+
Response or None if cache is invalid/missing
|
192
|
+
"""
|
193
|
+
result = await storage.retrieve(cache_key)
|
194
|
+
if result is None:
|
195
|
+
return None
|
196
|
+
response, _, _ = result
|
197
|
+
return response
|
198
|
+
|
199
|
+
async def invalidate_cache(
|
200
|
+
self,
|
201
|
+
cache_drop_config: CacheDropConfig,
|
202
|
+
storage: BaseStorage,
|
203
|
+
) -> None:
|
204
|
+
"""Invalidates cache by configuration.
|
205
|
+
|
206
|
+
Args:
|
207
|
+
cache_drop_config: Cache invalidation configuration
|
208
|
+
storage: Cache storage
|
209
|
+
|
210
|
+
TODO: Comments on improvements:
|
211
|
+
|
212
|
+
1. Need to add pattern support in storage for bulk invalidation
|
213
|
+
by key prefix/mask (especially for Redis/Memcached)
|
214
|
+
|
215
|
+
2. Desirable to add bulk operations for removing multiple keys
|
216
|
+
in one storage request
|
217
|
+
|
218
|
+
3. Can add delayed/asynchronous invalidation via queue
|
219
|
+
for large datasets
|
220
|
+
|
221
|
+
4. Should add invalidation strategies:
|
222
|
+
- Immediate (current implementation)
|
223
|
+
- Delayed (via TTL)
|
224
|
+
- Partial (only specific fields)
|
225
|
+
|
226
|
+
5. Add tag support for grouping related caches
|
227
|
+
and their joint invalidation
|
228
|
+
"""
|
229
|
+
for path in cache_drop_config.paths:
|
230
|
+
await storage.remove(path)
|
231
|
+
logger.info("Invalidated cache for pattern: %s", path.pattern)
|
@@ -0,0 +1,66 @@
|
|
1
|
+
import re
|
2
|
+
import typing as tp
|
3
|
+
|
4
|
+
from fastapi import params
|
5
|
+
from starlette.requests import Request
|
6
|
+
|
7
|
+
|
8
|
+
class BaseCacheConfigDepends(params.Depends):
|
9
|
+
"""Base class for cache configuration via ASGI scope extensions.
|
10
|
+
|
11
|
+
Uses standardized ASGI extensions mechanism for passing
|
12
|
+
configuration from route dependencies to middleware.
|
13
|
+
"""
|
14
|
+
|
15
|
+
use_cache: bool = True
|
16
|
+
|
17
|
+
def __call__(self, request: Request) -> None:
|
18
|
+
"""Saves configuration in ASGI scope extensions.
|
19
|
+
|
20
|
+
Args:
|
21
|
+
request: HTTP request
|
22
|
+
"""
|
23
|
+
# Use standard ASGI extensions mechanism
|
24
|
+
if "extensions" not in request.scope:
|
25
|
+
request.scope["extensions"] = {}
|
26
|
+
|
27
|
+
if "fast_cache" not in request.scope["extensions"]:
|
28
|
+
request.scope["extensions"]["fast_cache"] = {}
|
29
|
+
|
30
|
+
request.scope["extensions"]["fast_cache"]["config"] = self
|
31
|
+
|
32
|
+
@property
|
33
|
+
def dependency(self) -> params.Depends:
|
34
|
+
return self
|
35
|
+
|
36
|
+
|
37
|
+
class CacheConfig(BaseCacheConfigDepends):
|
38
|
+
"""Cache configuration for route.
|
39
|
+
|
40
|
+
Args:
|
41
|
+
max_age: Cache lifetime in seconds
|
42
|
+
key_func: Cache key generation function
|
43
|
+
"""
|
44
|
+
|
45
|
+
def __init__(
|
46
|
+
self,
|
47
|
+
max_age: int = 5 * 60,
|
48
|
+
key_func: tp.Optional[tp.Callable[[Request], str]] = None,
|
49
|
+
) -> None:
|
50
|
+
self.max_age = max_age
|
51
|
+
self.key_func = key_func
|
52
|
+
|
53
|
+
|
54
|
+
class CacheDropConfig(BaseCacheConfigDepends):
|
55
|
+
"""Cache invalidation configuration for route.
|
56
|
+
|
57
|
+
Args:
|
58
|
+
paths: Path for cache invalidation. Can be string or regular expression.
|
59
|
+
If string, it will be converted to regular expression
|
60
|
+
that matches the beginning of request path.
|
61
|
+
"""
|
62
|
+
|
63
|
+
def __init__(self, paths: list[str | re.Pattern]) -> None:
|
64
|
+
self.paths: list[re.Pattern] = [
|
65
|
+
p if isinstance(p, re.Pattern) else re.compile(f"^{p}$") for p in paths
|
66
|
+
]
|
@@ -0,0 +1,293 @@
|
|
1
|
+
import copy
|
2
|
+
import inspect
|
3
|
+
import logging
|
4
|
+
import typing as tp
|
5
|
+
|
6
|
+
from fastapi import FastAPI, routing
|
7
|
+
from starlette.requests import Request
|
8
|
+
from starlette.responses import Response
|
9
|
+
from starlette.routing import Mount, Route
|
10
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
11
|
+
|
12
|
+
from .controller import Controller
|
13
|
+
from .depends import BaseCacheConfigDepends, CacheConfig, CacheDropConfig
|
14
|
+
from .schemas import RouteInfo
|
15
|
+
from .storages import BaseStorage, InMemoryStorage
|
16
|
+
|
17
|
+
logger = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
|
20
|
+
def get_app_routes(app: FastAPI) -> tp.List[routing.APIRoute]:
|
21
|
+
"""Gets all routes from FastAPI application.
|
22
|
+
|
23
|
+
Recursively traverses all application routers and collects their routes.
|
24
|
+
|
25
|
+
Args:
|
26
|
+
app: FastAPI application
|
27
|
+
|
28
|
+
Returns:
|
29
|
+
List of all application routes
|
30
|
+
"""
|
31
|
+
routes = []
|
32
|
+
|
33
|
+
# Get routes from main application router
|
34
|
+
routes.extend(get_routes(app.router))
|
35
|
+
|
36
|
+
# Traverse all nested routers
|
37
|
+
for route in app.router.routes:
|
38
|
+
if isinstance(route, Mount):
|
39
|
+
if isinstance(route.app, routing.APIRouter):
|
40
|
+
routes.extend(get_routes(route.app))
|
41
|
+
|
42
|
+
return routes
|
43
|
+
|
44
|
+
|
45
|
+
def get_routes(router: routing.APIRouter) -> list[routing.APIRoute]:
|
46
|
+
"""Recursively gets all routes from router.
|
47
|
+
|
48
|
+
Traverses all routes in router and its sub-routers, collecting them into a single list.
|
49
|
+
|
50
|
+
Args:
|
51
|
+
router: APIRouter to traverse
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
List of all routes from router and its sub-routers
|
55
|
+
"""
|
56
|
+
routes = []
|
57
|
+
|
58
|
+
# Get all routes from current router
|
59
|
+
for route in router.routes:
|
60
|
+
if isinstance(route, routing.APIRoute):
|
61
|
+
routes.append(route)
|
62
|
+
elif isinstance(route, Mount):
|
63
|
+
# Recursively traverse sub-routers
|
64
|
+
if isinstance(route.app, routing.APIRouter):
|
65
|
+
routes.extend(get_routes(route.app))
|
66
|
+
|
67
|
+
return routes
|
68
|
+
|
69
|
+
|
70
|
+
async def send_with_callbacks(
|
71
|
+
app: ASGIApp,
|
72
|
+
scope: Scope,
|
73
|
+
receive: Receive,
|
74
|
+
send: Send,
|
75
|
+
on_response_ready: tp.Callable[[Response], tp.Awaitable[None]] | None = None,
|
76
|
+
) -> None:
|
77
|
+
response_holder: tp.Dict[str, tp.Any] = {}
|
78
|
+
|
79
|
+
async def response_builder(message: tp.Dict[str, tp.Any]) -> None:
|
80
|
+
"""Wrapper for intercepting and saving response."""
|
81
|
+
if message["type"] == "http.response.start":
|
82
|
+
response_holder["status"] = message["status"]
|
83
|
+
|
84
|
+
message.get("headers", []).append(
|
85
|
+
("X-Cache-Status".encode(), "MISS".encode())
|
86
|
+
)
|
87
|
+
response_holder["headers"] = [
|
88
|
+
(k.decode(), v.decode()) for k, v in message.get("headers", [])
|
89
|
+
]
|
90
|
+
|
91
|
+
response_holder["body"] = b""
|
92
|
+
elif message["type"] == "http.response.body":
|
93
|
+
body = message.get("body", b"")
|
94
|
+
response_holder["body"] += body
|
95
|
+
|
96
|
+
# If this is the last chunk, cache the response
|
97
|
+
if not message.get("more_body", False):
|
98
|
+
response = Response(
|
99
|
+
content=response_holder["body"],
|
100
|
+
status_code=response_holder["status"],
|
101
|
+
headers=dict(response_holder["headers"]),
|
102
|
+
)
|
103
|
+
|
104
|
+
# Call callback with ready response
|
105
|
+
if on_response_ready:
|
106
|
+
await on_response_ready(response)
|
107
|
+
|
108
|
+
# Pass event further
|
109
|
+
await send(message)
|
110
|
+
|
111
|
+
await app(scope, receive, response_builder)
|
112
|
+
|
113
|
+
|
114
|
+
class FastCacheMiddleware:
|
115
|
+
"""Middleware for caching responses in ASGI applications.
|
116
|
+
|
117
|
+
Route resolution approach:
|
118
|
+
1. Analyzes all routes and their dependencies at startup
|
119
|
+
2. Finds corresponding route by path and method on request
|
120
|
+
3. Extracts cache configuration from route dependencies
|
121
|
+
4. Performs standard caching/invalidation logic
|
122
|
+
|
123
|
+
Advantages:
|
124
|
+
- Pre-route analysis - fast configuration lookup
|
125
|
+
- Support for all FastAPI dependencies
|
126
|
+
- Flexible cache management at route level
|
127
|
+
- Efficient cache invalidation
|
128
|
+
|
129
|
+
Args:
|
130
|
+
app: ASGI application to wrap
|
131
|
+
storage: Cache storage (default InMemoryStorage)
|
132
|
+
controller: Controller for managing caching logic
|
133
|
+
"""
|
134
|
+
|
135
|
+
def __init__(
|
136
|
+
self,
|
137
|
+
app: ASGIApp,
|
138
|
+
storage: tp.Optional[BaseStorage] = None,
|
139
|
+
controller: tp.Optional[Controller] = None,
|
140
|
+
) -> None:
|
141
|
+
self.app = app
|
142
|
+
self.storage = storage or InMemoryStorage()
|
143
|
+
self.controller = controller or Controller()
|
144
|
+
|
145
|
+
self._routes_info: list[RouteInfo] = []
|
146
|
+
|
147
|
+
def _extract_routes_info(self, routes: list[routing.APIRoute]) -> list[RouteInfo]:
|
148
|
+
"""Recursively extracts route information and their dependencies.
|
149
|
+
|
150
|
+
Args:
|
151
|
+
routes: List of routes to analyze
|
152
|
+
"""
|
153
|
+
routes_info = []
|
154
|
+
for route in routes:
|
155
|
+
(
|
156
|
+
cache_config,
|
157
|
+
cache_drop_config,
|
158
|
+
) = self._extract_cache_configs_from_route(route)
|
159
|
+
|
160
|
+
if cache_config or cache_drop_config:
|
161
|
+
route_info = RouteInfo(
|
162
|
+
route=route,
|
163
|
+
cache_config=cache_config,
|
164
|
+
cache_drop_config=cache_drop_config,
|
165
|
+
)
|
166
|
+
routes_info.append(route_info)
|
167
|
+
|
168
|
+
return routes_info
|
169
|
+
|
170
|
+
def _extract_cache_configs_from_route(
|
171
|
+
self, route: routing.APIRoute
|
172
|
+
) -> tp.Tuple[CacheConfig | None, CacheDropConfig | None]:
|
173
|
+
"""Extracts cache configurations from route dependencies.
|
174
|
+
|
175
|
+
Args:
|
176
|
+
route: Route to analyze
|
177
|
+
|
178
|
+
Returns:
|
179
|
+
Tuple with CacheConfig and CacheDropConfig (if found)
|
180
|
+
"""
|
181
|
+
cache_config = None
|
182
|
+
cache_drop_config = None
|
183
|
+
|
184
|
+
endpoint = getattr(route, "endpoint", None)
|
185
|
+
if not endpoint:
|
186
|
+
return None, None
|
187
|
+
|
188
|
+
# Analyze dependencies if they exist
|
189
|
+
for dependency in getattr(route, "dependencies", []):
|
190
|
+
if isinstance(dependency, BaseCacheConfigDepends):
|
191
|
+
# need to make a copy, as dependency can be destroyed
|
192
|
+
dependency = copy.deepcopy(dependency)
|
193
|
+
if isinstance(dependency, CacheConfig):
|
194
|
+
cache_config = dependency
|
195
|
+
elif isinstance(dependency, CacheDropConfig):
|
196
|
+
cache_drop_config = dependency
|
197
|
+
continue
|
198
|
+
|
199
|
+
return cache_config, cache_drop_config
|
200
|
+
|
201
|
+
def _find_matching_route(
|
202
|
+
self, request: Request, routes_info: list[RouteInfo]
|
203
|
+
) -> tp.Optional[RouteInfo]:
|
204
|
+
"""Finds route matching the request.
|
205
|
+
|
206
|
+
Args:
|
207
|
+
request: HTTP request
|
208
|
+
|
209
|
+
Returns:
|
210
|
+
RouteInfo if matching route found, otherwise None
|
211
|
+
"""
|
212
|
+
for route_info in routes_info:
|
213
|
+
match_mode, _ = route_info.route.matches(request.scope)
|
214
|
+
if match_mode == routing.Match.FULL:
|
215
|
+
return route_info
|
216
|
+
|
217
|
+
return None
|
218
|
+
|
219
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
220
|
+
if scope["type"] != "http":
|
221
|
+
await self.app(scope, receive, send)
|
222
|
+
return
|
223
|
+
|
224
|
+
if not self._routes_info:
|
225
|
+
app_routes = get_app_routes(scope["app"])
|
226
|
+
self._routes_info = self._extract_routes_info(app_routes)
|
227
|
+
|
228
|
+
request = Request(scope, receive)
|
229
|
+
|
230
|
+
# Find matching route
|
231
|
+
route_info = self._find_matching_route(request, self._routes_info)
|
232
|
+
if not route_info:
|
233
|
+
await self.app(scope, receive, send)
|
234
|
+
return
|
235
|
+
|
236
|
+
# Handle invalidation if specified
|
237
|
+
if cc := route_info.cache_drop_config:
|
238
|
+
await self.controller.invalidate_cache(cc, storage=self.storage)
|
239
|
+
|
240
|
+
# Handle caching if config exists
|
241
|
+
if route_info.cache_config:
|
242
|
+
await self._handle_cache_request(route_info, request, scope, receive, send)
|
243
|
+
return
|
244
|
+
|
245
|
+
# Execute original request
|
246
|
+
await self.app(scope, receive, send)
|
247
|
+
|
248
|
+
async def _handle_cache_request(
|
249
|
+
self,
|
250
|
+
route_info: RouteInfo,
|
251
|
+
request: Request,
|
252
|
+
scope: Scope,
|
253
|
+
receive: Receive,
|
254
|
+
send: Send,
|
255
|
+
) -> None:
|
256
|
+
"""Handles request with caching.
|
257
|
+
|
258
|
+
Args:
|
259
|
+
route_info: Route information
|
260
|
+
request: HTTP request
|
261
|
+
scope: ASGI scope
|
262
|
+
receive: ASGI receive callable
|
263
|
+
send: ASGI send callable
|
264
|
+
"""
|
265
|
+
cache_config = route_info.cache_config
|
266
|
+
if not cache_config:
|
267
|
+
await self.app(scope, receive, send)
|
268
|
+
return
|
269
|
+
|
270
|
+
if not await self.controller.is_cachable_request(request):
|
271
|
+
await self.app(scope, receive, send)
|
272
|
+
return
|
273
|
+
|
274
|
+
cache_key = await self.controller.generate_cache_key(request, cache_config)
|
275
|
+
|
276
|
+
cached_response = await self.controller.get_cached_response(
|
277
|
+
cache_key, self.storage
|
278
|
+
)
|
279
|
+
if cached_response is not None:
|
280
|
+
logger.debug("Returning cached response for key: %s", cache_key)
|
281
|
+
await cached_response(scope, receive, send)
|
282
|
+
return
|
283
|
+
|
284
|
+
# Cache not found - execute request and cache result
|
285
|
+
await send_with_callbacks(
|
286
|
+
self.app,
|
287
|
+
scope,
|
288
|
+
receive,
|
289
|
+
send,
|
290
|
+
lambda response: self.controller.cache_response(
|
291
|
+
cache_key, request, response, self.storage, cache_config.max_age
|
292
|
+
),
|
293
|
+
)
|
@@ -0,0 +1,21 @@
|
|
1
|
+
import typing as tp
|
2
|
+
|
3
|
+
from starlette.routing import Route
|
4
|
+
|
5
|
+
from .depends import BaseCacheConfigDepends
|
6
|
+
|
7
|
+
|
8
|
+
class RouteInfo:
|
9
|
+
"""Route information with cache configuration."""
|
10
|
+
|
11
|
+
def __init__(
|
12
|
+
self,
|
13
|
+
route: Route,
|
14
|
+
cache_config: tp.Optional[BaseCacheConfigDepends] = None,
|
15
|
+
cache_drop_config: tp.Optional[BaseCacheConfigDepends] = None,
|
16
|
+
):
|
17
|
+
self.route = route
|
18
|
+
self.cache_config = cache_config
|
19
|
+
self.cache_drop_config = cache_drop_config
|
20
|
+
self.path: str = getattr(route, "path")
|
21
|
+
self.methods: tp.Set[str] = getattr(route, "methods", set())
|
@@ -0,0 +1,92 @@
|
|
1
|
+
import json
|
2
|
+
import typing as tp
|
3
|
+
|
4
|
+
from starlette.requests import Request
|
5
|
+
from starlette.responses import Response
|
6
|
+
|
7
|
+
# Define types for metadata and stored response
|
8
|
+
Metadata: tp.TypeAlias = tp.Dict[str, tp.Any] # todo: make it models
|
9
|
+
StoredResponse: tp.TypeAlias = tp.Tuple[Response, Request, Metadata]
|
10
|
+
|
11
|
+
|
12
|
+
class BaseSerializer:
|
13
|
+
def dumps(
|
14
|
+
self, response: Response, request: Request, metadata: Metadata
|
15
|
+
) -> tp.Union[str, bytes]:
|
16
|
+
raise NotImplementedError()
|
17
|
+
|
18
|
+
def loads(
|
19
|
+
self, data: tp.Union[str, bytes]
|
20
|
+
) -> tp.Tuple[Response, Request, Metadata]:
|
21
|
+
raise NotImplementedError()
|
22
|
+
|
23
|
+
@property
|
24
|
+
def is_binary(self) -> bool:
|
25
|
+
raise NotImplementedError()
|
26
|
+
|
27
|
+
|
28
|
+
class JSONSerializer(BaseSerializer):
|
29
|
+
def dumps(self, response: Response, request: Request, metadata: Metadata) -> str:
|
30
|
+
serialized = {
|
31
|
+
"response": {
|
32
|
+
"status_code": response.status_code,
|
33
|
+
"headers": [[k.decode(), v.decode()] for k, v in response.headers.raw],
|
34
|
+
"content": (
|
35
|
+
response.body.decode("utf-8", errors="ignore")
|
36
|
+
if response.body
|
37
|
+
else None
|
38
|
+
),
|
39
|
+
},
|
40
|
+
"request": {
|
41
|
+
"method": request.method,
|
42
|
+
"url": str(request.url),
|
43
|
+
"headers": [[k.decode(), v.decode()] for k, v in request.headers.raw],
|
44
|
+
},
|
45
|
+
"metadata": metadata,
|
46
|
+
}
|
47
|
+
return json.dumps(serialized)
|
48
|
+
|
49
|
+
def loads(self, data: tp.Union[str, bytes]) -> StoredResponse:
|
50
|
+
if isinstance(data, bytes):
|
51
|
+
data = data.decode()
|
52
|
+
|
53
|
+
parsed = json.loads(data)
|
54
|
+
|
55
|
+
# Restore Response
|
56
|
+
response_data = parsed["response"]
|
57
|
+
response = Response(
|
58
|
+
content=(
|
59
|
+
response_data["content"].encode("utf-8")
|
60
|
+
if response_data["content"]
|
61
|
+
else b""
|
62
|
+
),
|
63
|
+
status_code=response_data["status_code"],
|
64
|
+
headers=dict(response_data["headers"]),
|
65
|
+
)
|
66
|
+
|
67
|
+
# Restore Request - create mock object for compatibility
|
68
|
+
request_data = parsed["request"]
|
69
|
+
|
70
|
+
# Create minimal scope for Request
|
71
|
+
from urllib.parse import urlparse
|
72
|
+
|
73
|
+
parsed_url = urlparse(request_data["url"])
|
74
|
+
scope = {
|
75
|
+
"type": "http",
|
76
|
+
"method": request_data["method"],
|
77
|
+
"path": parsed_url.path,
|
78
|
+
"query_string": parsed_url.query.encode() if parsed_url.query else b"",
|
79
|
+
"headers": [[k.encode(), v.encode()] for k, v in request_data["headers"]],
|
80
|
+
}
|
81
|
+
|
82
|
+
# Create empty receive function
|
83
|
+
async def receive():
|
84
|
+
return {"type": "http.request", "body": b""}
|
85
|
+
|
86
|
+
request = Request(scope, receive)
|
87
|
+
|
88
|
+
return response, request, parsed["metadata"]
|
89
|
+
|
90
|
+
@property
|
91
|
+
def is_binary(self) -> bool:
|
92
|
+
return False
|
@@ -0,0 +1,238 @@
|
|
1
|
+
import logging
|
2
|
+
import re
|
3
|
+
import time
|
4
|
+
import typing as tp
|
5
|
+
from collections import OrderedDict
|
6
|
+
|
7
|
+
from starlette.requests import Request
|
8
|
+
from starlette.responses import Response
|
9
|
+
from typing_extensions import TypeAlias
|
10
|
+
|
11
|
+
from .exceptions import StorageError
|
12
|
+
from .serializers import BaseSerializer, JSONSerializer, Metadata
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
# Define type for stored response
|
17
|
+
StoredResponse: TypeAlias = tp.Tuple[Response, Request, Metadata]
|
18
|
+
|
19
|
+
|
20
|
+
# Define base class for cache storage
|
21
|
+
class BaseStorage:
|
22
|
+
"""Base class for cache storage.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
serializer: Serializer for converting Response/Request to string/bytes
|
26
|
+
ttl: Cache lifetime in seconds. None for permanent storage
|
27
|
+
"""
|
28
|
+
|
29
|
+
def __init__(
|
30
|
+
self,
|
31
|
+
serializer: tp.Optional[BaseSerializer] = None,
|
32
|
+
ttl: tp.Optional[tp.Union[int, float]] = None,
|
33
|
+
) -> None:
|
34
|
+
self._serializer = serializer or JSONSerializer()
|
35
|
+
|
36
|
+
if ttl is not None and ttl <= 0:
|
37
|
+
raise StorageError("TTL must be positive")
|
38
|
+
|
39
|
+
self._ttl = ttl
|
40
|
+
|
41
|
+
async def store(
|
42
|
+
self, key: str, response: Response, request: Request, metadata: Metadata
|
43
|
+
) -> None:
|
44
|
+
raise NotImplementedError()
|
45
|
+
|
46
|
+
async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
|
47
|
+
raise NotImplementedError()
|
48
|
+
|
49
|
+
async def remove(self, path: re.Pattern) -> None:
|
50
|
+
raise NotImplementedError()
|
51
|
+
|
52
|
+
async def close(self) -> None:
|
53
|
+
raise NotImplementedError()
|
54
|
+
|
55
|
+
|
56
|
+
class InMemoryStorage(BaseStorage):
|
57
|
+
"""In-memory cache storage with TTL and LRU eviction support.
|
58
|
+
|
59
|
+
Implements optimized storage of cached responses in memory with:
|
60
|
+
- LRU (Least Recently Used) eviction when max_size is exceeded
|
61
|
+
- TTL (Time To Live) with lazy checking on read
|
62
|
+
- Batch cleanup for better performance
|
63
|
+
|
64
|
+
Args:
|
65
|
+
max_size: Maximum number of cache entries
|
66
|
+
serializer: Serializer not used for InMemoryStorage
|
67
|
+
ttl: Cache lifetime in seconds. None for permanent storage
|
68
|
+
"""
|
69
|
+
|
70
|
+
def __init__(
|
71
|
+
self,
|
72
|
+
max_size: int = 1000,
|
73
|
+
serializer: tp.Optional[BaseSerializer] = None,
|
74
|
+
ttl: tp.Optional[tp.Union[int, float]] = None,
|
75
|
+
) -> None:
|
76
|
+
super().__init__(serializer=serializer, ttl=ttl)
|
77
|
+
|
78
|
+
if max_size <= 0:
|
79
|
+
raise StorageError("Max size must be positive")
|
80
|
+
|
81
|
+
self._max_size = max_size
|
82
|
+
# Cleanup batch size - default 10% of max_size, minimum 1
|
83
|
+
self._cleanup_batch_size = max(1, max_size // 10)
|
84
|
+
# Cleanup threshold - 5% more than max_size
|
85
|
+
self._cleanup_threshold = max_size + max(1, max_size // 20)
|
86
|
+
|
87
|
+
# OrderedDict for efficient LRU
|
88
|
+
self._storage: OrderedDict[str, StoredResponse] = OrderedDict()
|
89
|
+
# Separate expiry time storage for fast TTL checking
|
90
|
+
self._expiry_times: tp.Dict[str, float] = {}
|
91
|
+
self._last_expiry_check_time: float = 0
|
92
|
+
self._expiry_check_interval: float = 60
|
93
|
+
|
94
|
+
async def store(
|
95
|
+
self, key: str, response: Response, request: Request, metadata: Metadata
|
96
|
+
) -> None:
|
97
|
+
"""Saves response to cache with TTL and LRU eviction support.
|
98
|
+
|
99
|
+
If element already exists, it moves to the end (most recently used).
|
100
|
+
When size limit is exceeded, batch cleanup of old elements starts.
|
101
|
+
|
102
|
+
Args:
|
103
|
+
key: Key for saving
|
104
|
+
response: HTTP response to cache
|
105
|
+
request: Original HTTP request
|
106
|
+
metadata: Cache metadata
|
107
|
+
"""
|
108
|
+
current_time = time.time()
|
109
|
+
|
110
|
+
# Update metadata
|
111
|
+
metadata = metadata.copy()
|
112
|
+
metadata["write_time"] = current_time
|
113
|
+
|
114
|
+
# If element already exists, remove it (it will be added to the end)
|
115
|
+
if key in self._storage:
|
116
|
+
logger.info("Element %s removed from cache - overwrite", key)
|
117
|
+
self._pop_item(key)
|
118
|
+
|
119
|
+
self._storage[key] = (response, request, metadata)
|
120
|
+
|
121
|
+
data_ttl = metadata.get("ttl", self._ttl)
|
122
|
+
if data_ttl is not None:
|
123
|
+
self._expiry_times[key] = current_time + data_ttl
|
124
|
+
|
125
|
+
self._remove_expired_items()
|
126
|
+
|
127
|
+
self._cleanup_lru_items()
|
128
|
+
|
129
|
+
async def retrieve(self, key: str) -> tp.Optional[StoredResponse]:
|
130
|
+
"""Gets response from cache with lazy TTL checking.
|
131
|
+
|
132
|
+
Element moves to the end to update LRU position.
|
133
|
+
Expired elements are automatically removed.
|
134
|
+
|
135
|
+
Args:
|
136
|
+
key: Key to search
|
137
|
+
|
138
|
+
Returns:
|
139
|
+
Tuple (response, request, metadata) if found and not expired, None if not found or expired
|
140
|
+
"""
|
141
|
+
if key not in self._storage:
|
142
|
+
return None
|
143
|
+
|
144
|
+
# Lazy TTL check
|
145
|
+
if self._is_expired(key):
|
146
|
+
self._pop_item(key)
|
147
|
+
logger.debug("Element %s removed from cache - TTL expired", key)
|
148
|
+
return None
|
149
|
+
|
150
|
+
self._storage.move_to_end(key)
|
151
|
+
|
152
|
+
return self._storage[key]
|
153
|
+
|
154
|
+
async def remove(self, path: re.Pattern) -> None:
|
155
|
+
"""Removes responses from cache by request path pattern.
|
156
|
+
|
157
|
+
Args:
|
158
|
+
path: Regular expression for matching request paths
|
159
|
+
"""
|
160
|
+
# Find all keys matching path pattern
|
161
|
+
keys_to_remove = []
|
162
|
+
for key, (_, request, _) in self._storage.items():
|
163
|
+
if path.match(request.url.path):
|
164
|
+
keys_to_remove.append(key)
|
165
|
+
|
166
|
+
# Remove found keys
|
167
|
+
for key in keys_to_remove:
|
168
|
+
self._pop_item(key)
|
169
|
+
|
170
|
+
logger.debug(
|
171
|
+
"Removed %d entries from cache by pattern %s",
|
172
|
+
len(keys_to_remove),
|
173
|
+
path.pattern,
|
174
|
+
)
|
175
|
+
|
176
|
+
async def close(self) -> None:
|
177
|
+
"""Clears storage and frees resources."""
|
178
|
+
self._storage.clear()
|
179
|
+
self._expiry_times.clear()
|
180
|
+
logger.debug("Cache storage cleared")
|
181
|
+
|
182
|
+
def __len__(self) -> int:
|
183
|
+
"""Returns current number of elements in cache."""
|
184
|
+
return len(self._storage)
|
185
|
+
|
186
|
+
def _pop_item(self, key: str) -> StoredResponse | None:
|
187
|
+
"""Removes element from storage and expiry times.
|
188
|
+
|
189
|
+
Args:
|
190
|
+
key: Element key to remove
|
191
|
+
"""
|
192
|
+
self._expiry_times.pop(key, None)
|
193
|
+
return self._storage.pop(key, None)
|
194
|
+
|
195
|
+
def _is_expired(self, key: str) -> bool:
|
196
|
+
"""Checks if element is expired by TTL."""
|
197
|
+
try:
|
198
|
+
return time.time() > self._expiry_times[key]
|
199
|
+
except KeyError:
|
200
|
+
return False
|
201
|
+
|
202
|
+
def _remove_expired_items(self) -> None:
|
203
|
+
"""Removes all expired elements from cache."""
|
204
|
+
current_time = time.time()
|
205
|
+
|
206
|
+
if current_time - self._last_expiry_check_time < self._expiry_check_interval:
|
207
|
+
return
|
208
|
+
|
209
|
+
self._last_expiry_check_time = current_time
|
210
|
+
|
211
|
+
expired_keys = [
|
212
|
+
key
|
213
|
+
for key, expiry_time in self._expiry_times.items()
|
214
|
+
if current_time > expiry_time
|
215
|
+
]
|
216
|
+
if not expired_keys:
|
217
|
+
return
|
218
|
+
|
219
|
+
for key in expired_keys:
|
220
|
+
self._pop_item(key)
|
221
|
+
|
222
|
+
logger.debug("Removed %d expired elements from cache", len(expired_keys))
|
223
|
+
|
224
|
+
def _cleanup_lru_items(self) -> None:
|
225
|
+
"""Removes old elements by LRU strategy when limit is exceeded."""
|
226
|
+
if len(self._storage) <= self._cleanup_threshold:
|
227
|
+
return
|
228
|
+
|
229
|
+
# Remove elements in batches for better performance
|
230
|
+
items_to_remove = min(
|
231
|
+
self._cleanup_batch_size, len(self._storage) - self._max_size
|
232
|
+
)
|
233
|
+
|
234
|
+
for _ in range(items_to_remove):
|
235
|
+
key, _ = self._storage.popitem(last=False) # FIFO
|
236
|
+
self._expiry_times.pop(key, None)
|
237
|
+
|
238
|
+
logger.debug("Removed %d elements from cache by LRU strategy", items_to_remove)
|
@@ -0,0 +1,370 @@
|
|
1
|
+
Metadata-Version: 2.3
|
2
|
+
Name: fast-cache-middleware
|
3
|
+
Version: 0.0.1
|
4
|
+
Summary: Интеллектуальное middleware для кеширования ответов FastAPI
|
5
|
+
License: MIT
|
6
|
+
Author: Your Name
|
7
|
+
Author-email: your.email@example.com
|
8
|
+
Requires-Python: >=3.11,<4.0
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
14
|
+
Requires-Dist: fastapi (>=0.111.1,<0.112.0)
|
15
|
+
Description-Content-Type: text/markdown
|
16
|
+
|
17
|
+
# FastCacheMiddleware
|
18
|
+
|
19
|
+
🚀 **Высокопроизводительный ASGI middleware для кеширования с резолюцией роутов**
|
20
|
+
|
21
|
+
## ✨ Основные особенности
|
22
|
+
|
23
|
+
FastCacheMiddleware использует **подход с резолюцией роутов** - анализирует роуты приложения на старте и извлекает кеш конфигурации из FastAPI dependencies.
|
24
|
+
|
25
|
+
### 🔧 Как это работает
|
26
|
+
|
27
|
+
1. **При старте приложения:**
|
28
|
+
- Middleware анализирует все роуты и их dependencies
|
29
|
+
- Извлекает `CacheConfig` и `CacheDropConfig` из dependencies
|
30
|
+
- Создает внутренний индекс роутов с конфигурациями кеширования
|
31
|
+
|
32
|
+
2. **При обработке запроса:**
|
33
|
+
- Проверяет HTTP метод (кешируем только GET, инвалидируем для POST/PUT/DELETE)
|
34
|
+
- Находит соответствующий роут по пути и методу
|
35
|
+
- Извлекает кеш конфигурацию из предварительно проанализированных dependencies
|
36
|
+
- Выполняет кеширование или инвалидацию согласно конфигурации
|
37
|
+
|
38
|
+
### 💡 Преимущества
|
39
|
+
|
40
|
+
- **⚡ Высокая производительность** - предварительный анализ роутов
|
41
|
+
- **🎯 Простая интеграция** - стандартные FastAPI dependencies
|
42
|
+
- **🔧 Гибкая настройка** - кастомные функции ключей, TTL на уровне роутов
|
43
|
+
- **🛡️ Автоматическая инвалидация** - инвалидация кеша при модифицирующих запросах
|
44
|
+
- **📊 Минимальные накладные расходы** - эффективная работа с большим количеством роутов
|
45
|
+
|
46
|
+
## 📦 Установка
|
47
|
+
|
48
|
+
```bash
|
49
|
+
pip install fast-cache-middleware
|
50
|
+
```
|
51
|
+
|
52
|
+
## 🎯 Быстрый старт
|
53
|
+
|
54
|
+
```python
|
55
|
+
from fastapi import FastAPI, Depends
|
56
|
+
from fast_cache_middleware import FastCacheMiddleware, CacheConfig, CacheDropConfig
|
57
|
+
|
58
|
+
app = FastAPI()
|
59
|
+
|
60
|
+
# Добавляем middleware - он автоматически анализирует роуты
|
61
|
+
app.add_middleware(FastCacheMiddleware)
|
62
|
+
|
63
|
+
# Функции для создания кеш конфигураций
|
64
|
+
def cache_5min() -> CacheConfig:
|
65
|
+
return CacheConfig(max_age=300) # 5 минут
|
66
|
+
|
67
|
+
def cache_with_custom_key() -> CacheConfig:
|
68
|
+
def custom_key_func(request):
|
69
|
+
user_id = request.headers.get("user-id", "anonymous")
|
70
|
+
return f"{request.url.path}:user:{user_id}"
|
71
|
+
|
72
|
+
return CacheConfig(max_age=60, key_func=custom_key_func)
|
73
|
+
|
74
|
+
def invalidate_users() -> CacheDropConfig:
|
75
|
+
return CacheDropConfig(paths=["/users/*", "/api/users/*"])
|
76
|
+
|
77
|
+
# Роуты с кешированием
|
78
|
+
@app.get("/users/{user_id}", dependencies=[Depends(cache_5min)])
|
79
|
+
async def get_user(user_id: int):
|
80
|
+
"""Этот endpoint кешируется на 5 минут."""
|
81
|
+
# Имитируем загрузку из БД
|
82
|
+
return {"user_id": user_id, "name": f"User {user_id}"}
|
83
|
+
|
84
|
+
@app.get("/profile", dependencies=[Depends(cache_with_custom_key)])
|
85
|
+
async def get_profile():
|
86
|
+
"""Кеширование с персонализированным ключом."""
|
87
|
+
return {"profile": "user profile data"}
|
88
|
+
|
89
|
+
# Роуты с инвалидацией кеша
|
90
|
+
@app.post("/users/{user_id}", dependencies=[Depends(invalidate_users)])
|
91
|
+
async def update_user(user_id: int, data: dict):
|
92
|
+
"""POST запрос инвалидирует кеш для всех /users/* путей."""
|
93
|
+
return {"user_id": user_id, "status": "updated"}
|
94
|
+
```
|
95
|
+
|
96
|
+
## 🔧 Конфигурация
|
97
|
+
|
98
|
+
### CacheConfig
|
99
|
+
|
100
|
+
Настройка кеширования для GET запросов:
|
101
|
+
|
102
|
+
```python
|
103
|
+
from fast_cache_middleware import CacheConfig
|
104
|
+
|
105
|
+
# Простое кеширование
|
106
|
+
def simple_cache() -> CacheConfig:
|
107
|
+
return CacheConfig(max_age=300) # 5 минут
|
108
|
+
|
109
|
+
# С кастомной функцией ключа
|
110
|
+
def personalized_cache() -> CacheConfig:
|
111
|
+
def key_func(request):
|
112
|
+
user_id = request.headers.get("user-id", "anonymous")
|
113
|
+
path = request.url.path
|
114
|
+
query = str(request.query_params)
|
115
|
+
return f"{path}:{user_id}:{query}"
|
116
|
+
|
117
|
+
return CacheConfig(
|
118
|
+
max_age=600, # 10 минут
|
119
|
+
key_func=key_func
|
120
|
+
)
|
121
|
+
|
122
|
+
@app.get("/api/data", dependencies=[Depends(personalized_cache)])
|
123
|
+
async def get_data():
|
124
|
+
return {"data": "personalized response"}
|
125
|
+
```
|
126
|
+
|
127
|
+
### CacheDropConfig
|
128
|
+
|
129
|
+
Настройка инвалидации кеша для модифицирующих запросов:
|
130
|
+
|
131
|
+
```python
|
132
|
+
from fast_cache_middleware import CacheDropConfig
|
133
|
+
|
134
|
+
def invalidate_multiple_paths() -> CacheDropConfig:
|
135
|
+
return CacheDropConfig(paths=[
|
136
|
+
"/users/*", # Все пути пользователей
|
137
|
+
"/api/users/*", # API пользователей
|
138
|
+
"/cache/users/*" # Кеш пользователей
|
139
|
+
])
|
140
|
+
|
141
|
+
@app.post("/users/{user_id}")
|
142
|
+
@app.put("/users/{user_id}")
|
143
|
+
@app.delete("/users/{user_id}")
|
144
|
+
async def modify_user(user_id: int):
|
145
|
+
"""Любой из этих запросов инвалидирует кеш."""
|
146
|
+
return {"message": "User modified"}
|
147
|
+
```
|
148
|
+
|
149
|
+
## 🏗️ Архитектура
|
150
|
+
|
151
|
+
### Компоненты системы
|
152
|
+
|
153
|
+
```
|
154
|
+
FastCacheMiddleware
|
155
|
+
├── RouteInfo # Информация о роуте с кеш конфигурацией
|
156
|
+
├── Controller # Логика кеширования и валидации
|
157
|
+
├── Storage # Хранилища (InMemory, Redis, и др.)
|
158
|
+
├── Serializers # Сериализация кешированных данных
|
159
|
+
└── Dependencies # FastAPI dependencies для конфигурации
|
160
|
+
```
|
161
|
+
|
162
|
+
### Поток обработки запроса
|
163
|
+
|
164
|
+
```mermaid
|
165
|
+
graph TD
|
166
|
+
A[HTTP Request] --> B{Анализ роутов выполнен?}
|
167
|
+
B -->|Нет| C[Анализировать роуты приложения]
|
168
|
+
C --> D[Сохранить конфигурации роутов]
|
169
|
+
B -->|Да| E{Метод поддерживает кеширование?}
|
170
|
+
D --> E
|
171
|
+
E -->|Нет| F[Передать в приложение]
|
172
|
+
E -->|Да| G[Найти соответствующий роут]
|
173
|
+
G --> H{Роут найден?}
|
174
|
+
H -->|Нет| F
|
175
|
+
H -->|Да| I{GET запрос + CacheConfig?}
|
176
|
+
I -->|Да| J[Проверить кеш]
|
177
|
+
J --> K{Кеш найден?}
|
178
|
+
K -->|Да| L[Вернуть из кеша]
|
179
|
+
K -->|Нет| M[Выполнить запрос + сохранить в кеш]
|
180
|
+
I -->|Нет| N{POST/PUT/DELETE + CacheDropConfig?}
|
181
|
+
N -->|Да| O[Инвалидировать кеш]
|
182
|
+
N -->|Нет| F
|
183
|
+
O --> F
|
184
|
+
M --> P[Вернуть ответ]
|
185
|
+
```
|
186
|
+
|
187
|
+
## 🎛️ Хранилища
|
188
|
+
|
189
|
+
### InMemoryStorage (по умолчанию)
|
190
|
+
|
191
|
+
```python
|
192
|
+
from fast_cache_middleware import FastCacheMiddleware, InMemoryStorage
|
193
|
+
|
194
|
+
storage = InMemoryStorage(
|
195
|
+
max_size=1000, # Максимум записей
|
196
|
+
cleanup_interval=3600 # Очистка каждый час
|
197
|
+
)
|
198
|
+
app.add_middleware(FastCacheMiddleware, storage=storage)
|
199
|
+
```
|
200
|
+
|
201
|
+
### Кастомное хранилище
|
202
|
+
|
203
|
+
```python
|
204
|
+
from fast_cache_middleware import BaseStorage
|
205
|
+
|
206
|
+
class RedisStorage(BaseStorage):
|
207
|
+
def __init__(self, redis_url: str):
|
208
|
+
import redis
|
209
|
+
self.redis = redis.from_url(redis_url)
|
210
|
+
|
211
|
+
async def store(self, key: str, response, request, metadata):
|
212
|
+
# Реализация сохранения в Redis
|
213
|
+
pass
|
214
|
+
|
215
|
+
async def retrieve(self, key: str):
|
216
|
+
# Реализация извлечения из Redis
|
217
|
+
pass
|
218
|
+
|
219
|
+
app.add_middleware(FastCacheMiddleware, storage=RedisStorage("redis://localhost"))
|
220
|
+
```
|
221
|
+
|
222
|
+
## 🧪 Тестирование
|
223
|
+
|
224
|
+
```python
|
225
|
+
import pytest
|
226
|
+
from httpx import AsyncClient
|
227
|
+
from examples.basic import app
|
228
|
+
|
229
|
+
@pytest.mark.asyncio
|
230
|
+
async def test_caching():
|
231
|
+
async with AsyncClient(app=app, base_url="http://test") as client:
|
232
|
+
# Первый запрос - cache miss
|
233
|
+
response1 = await client.get("/users/1")
|
234
|
+
assert response1.status_code == 200
|
235
|
+
|
236
|
+
# Второй запрос - cache hit (должен быть быстрее)
|
237
|
+
response2 = await client.get("/users/1")
|
238
|
+
assert response2.status_code == 200
|
239
|
+
assert response1.json() == response2.json()
|
240
|
+
|
241
|
+
@pytest.mark.asyncio
|
242
|
+
async def test_cache_invalidation():
|
243
|
+
async with AsyncClient(app=app, base_url="http://test") as client:
|
244
|
+
# Кешируем данные
|
245
|
+
await client.get("/users/1")
|
246
|
+
|
247
|
+
# Инвалидируем кеш
|
248
|
+
await client.post("/users/1", json={})
|
249
|
+
|
250
|
+
# Следующий GET должен выполнить новый запрос
|
251
|
+
response = await client.get("/users/1")
|
252
|
+
assert response.status_code == 200
|
253
|
+
```
|
254
|
+
|
255
|
+
## 📊 Производительность
|
256
|
+
|
257
|
+
### Бенчмарки
|
258
|
+
|
259
|
+
- **Анализ роутов**: ~5ms для 100 роутов при старте
|
260
|
+
- **Поиск роута**: ~0.1ms на запрос (O(n) по количеству кешируемых роутов)
|
261
|
+
- **Cache hit**: ~1ms на запрос
|
262
|
+
- **Cache miss**: время оригинального запроса + ~2ms на сохранение
|
263
|
+
|
264
|
+
### Оптимизация
|
265
|
+
|
266
|
+
```python
|
267
|
+
# Для приложений с большим количеством роутов
|
268
|
+
app.add_middleware(
|
269
|
+
FastCacheMiddleware,
|
270
|
+
storage=InMemoryStorage(max_size=10000), # Увеличить размер кеша
|
271
|
+
controller=Controller(default_ttl=3600) # Увеличить TTL по умолчанию
|
272
|
+
)
|
273
|
+
```
|
274
|
+
|
275
|
+
## 🔒 Безопасность
|
276
|
+
|
277
|
+
### Изоляция кеша
|
278
|
+
|
279
|
+
```python
|
280
|
+
def user_specific_cache() -> CacheConfig:
|
281
|
+
def secure_key_func(request):
|
282
|
+
# Включаем токен пользователя в ключ
|
283
|
+
token = request.headers.get("authorization", "").split(" ")[-1]
|
284
|
+
return f"{request.url.path}:token:{token}"
|
285
|
+
|
286
|
+
return CacheConfig(max_age=300, key_func=secure_key_func)
|
287
|
+
|
288
|
+
@app.get("/private/data", dependencies=[Depends(user_specific_cache)])
|
289
|
+
async def get_private_data():
|
290
|
+
return {"sensitive": "data"}
|
291
|
+
```
|
292
|
+
|
293
|
+
### Валидация заголовков
|
294
|
+
|
295
|
+
Middleware автоматически учитывает стандартные HTTP заголовки кеширования:
|
296
|
+
|
297
|
+
- `Cache-Control: no-cache` - пропуск кеша
|
298
|
+
- `Cache-Control: no-store` - запрет кеширования
|
299
|
+
- `If-None-Match` - проверка ETag
|
300
|
+
- `If-Modified-Since` - проверка времени модификации
|
301
|
+
|
302
|
+
## 🛠️ Продвинутое использование
|
303
|
+
|
304
|
+
### Кастомный Controller
|
305
|
+
|
306
|
+
```python
|
307
|
+
from fast_cache_middleware import Controller
|
308
|
+
|
309
|
+
class CustomController(Controller):
|
310
|
+
async def should_cache_request(self, request):
|
311
|
+
# Кастомная логика - не кешируем админские запросы
|
312
|
+
if request.headers.get("x-admin-request"):
|
313
|
+
return False
|
314
|
+
return await super().should_cache_request(request)
|
315
|
+
|
316
|
+
async def generate_cache_key(self, request):
|
317
|
+
# Добавляем версию API в ключ
|
318
|
+
version = request.headers.get("api-version", "v1")
|
319
|
+
base_key = await super().generate_cache_key(request)
|
320
|
+
return f"{version}:{base_key}"
|
321
|
+
|
322
|
+
app.add_middleware(
|
323
|
+
FastCacheMiddleware,
|
324
|
+
controller=CustomController()
|
325
|
+
)
|
326
|
+
```
|
327
|
+
|
328
|
+
### Мониторинг
|
329
|
+
|
330
|
+
```python
|
331
|
+
@app.get("/admin/cache/stats")
|
332
|
+
async def cache_stats():
|
333
|
+
# В production здесь будет реальная статистика из storage
|
334
|
+
return {
|
335
|
+
"total_routes": len(app.routes),
|
336
|
+
"cached_routes": "статистика по кешируемым роутам",
|
337
|
+
"cache_hit_rate": "процент попаданий в кеш",
|
338
|
+
"storage_size": "размер хранилища"
|
339
|
+
}
|
340
|
+
```
|
341
|
+
|
342
|
+
## 📝 Примеры
|
343
|
+
|
344
|
+
Больше примеров в папке `examples/`:
|
345
|
+
|
346
|
+
- **basic.py** - базовое использование с FastAPI
|
347
|
+
- **advanced.py** - продвинутые сценарии
|
348
|
+
- **custom_storage.py** - интеграция с Redis/Memcached
|
349
|
+
- **monitoring.py** - мониторинг и метрики
|
350
|
+
|
351
|
+
## 🤝 Участие в разработке
|
352
|
+
|
353
|
+
```bash
|
354
|
+
git clone https://github.com/your-username/fast-cache-middleware
|
355
|
+
cd fast-cache-middleware
|
356
|
+
pip install -e ".[dev]"
|
357
|
+
pytest
|
358
|
+
```
|
359
|
+
|
360
|
+
## 📄 Лицензия
|
361
|
+
|
362
|
+
MIT License - см. [LICENSE](LICENSE)
|
363
|
+
|
364
|
+
---
|
365
|
+
|
366
|
+
⭐ **Нравится проект? Поставьте звездочку!**
|
367
|
+
|
368
|
+
🐛 **Нашли баг?** [Создайте issue](https://github.com/your-username/fast-cache-middleware/issues)
|
369
|
+
|
370
|
+
💡 **Есть идея?** [Предложите feature](https://github.com/your-username/fast-cache-middleware/discussions)
|
@@ -0,0 +1,11 @@
|
|
1
|
+
fast_cache_middleware/__init__.py,sha256=Uk8DeGM0DM75NXv5wl9nw8p2dEdnFLqcowikS_TKPcQ,917
|
2
|
+
fast_cache_middleware/controller.py,sha256=gYcTCUU4jJO-ULX7AEbbYuN7VDcWoH1MmH2GoOnfqfc,7412
|
3
|
+
fast_cache_middleware/depends.py,sha256=CQmvIip7aGr7R-JBQS5n6UwCeAv0ZLnzIHjeGzWuPd0,1934
|
4
|
+
fast_cache_middleware/exceptions.py,sha256=3Be38HYfAS6QD028md5ku2ss7GehfyyXbDaEj91JuA4,115
|
5
|
+
fast_cache_middleware/middleware.py,sha256=llYX0Cz5GhFglxoNLnUyFCUp4ID8qfrUHIbRY7yy23g,9647
|
6
|
+
fast_cache_middleware/schemas.py,sha256=c1GXLs9FK8ollCF0wrgmzi4Ccpi49P1nOsJRL3rg1bk,631
|
7
|
+
fast_cache_middleware/serializers.py,sha256=ieMblO2Or3BdvBH2rOxMC2qabr21bJ-9MFt6y8y5s6c,2999
|
8
|
+
fast_cache_middleware/storages.py,sha256=DtalrNIqvCQrbBLwvicpsH6vfQFwsdDjrTvI329y39s,7849
|
9
|
+
fast_cache_middleware-0.0.1.dist-info/METADATA,sha256=GgU-D0Gwu_NXrrWdrouDKpvFQqkAEPHhnu9qIr_Ye7c,13663
|
10
|
+
fast_cache_middleware-0.0.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
11
|
+
fast_cache_middleware-0.0.1.dist-info/RECORD,,
|