fast-cache-middleware 0.0.1__py3-none-any.whl → 0.0.2__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 +35 -34
- fast_cache_middleware/controller.py +231 -231
- fast_cache_middleware/depends.py +66 -66
- fast_cache_middleware/exceptions.py +6 -6
- fast_cache_middleware/middleware.py +298 -293
- fast_cache_middleware/schemas.py +21 -21
- fast_cache_middleware/serializers.py +92 -92
- fast_cache_middleware/storages.py +238 -238
- {fast_cache_middleware-0.0.1.dist-info → fast_cache_middleware-0.0.2.dist-info}/METADATA +3 -2
- fast_cache_middleware-0.0.2.dist-info/RECORD +11 -0
- fast_cache_middleware-0.0.1.dist-info/RECORD +0 -11
- {fast_cache_middleware-0.0.1.dist-info → fast_cache_middleware-0.0.2.dist-info}/WHEEL +0 -0
@@ -1,6 +1,6 @@
|
|
1
|
-
class FastCacheMiddlewareError(Exception):
|
2
|
-
pass
|
3
|
-
|
4
|
-
|
5
|
-
class StorageError(FastCacheMiddlewareError):
|
6
|
-
pass
|
1
|
+
class FastCacheMiddlewareError(Exception):
|
2
|
+
pass
|
3
|
+
|
4
|
+
|
5
|
+
class StorageError(FastCacheMiddlewareError):
|
6
|
+
pass
|
@@ -1,293 +1,298 @@
|
|
1
|
-
import copy
|
2
|
-
import inspect
|
3
|
-
import logging
|
4
|
-
import typing as tp
|
5
|
-
|
6
|
-
|
7
|
-
from
|
8
|
-
from starlette.
|
9
|
-
from starlette.
|
10
|
-
from starlette.
|
11
|
-
|
12
|
-
|
13
|
-
from .
|
14
|
-
from .
|
15
|
-
from .
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
routes
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
-
|
126
|
-
-
|
127
|
-
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
self.
|
143
|
-
self.
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
self.
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
if
|
238
|
-
await self.
|
239
|
-
|
240
|
-
|
241
|
-
if
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
#
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
1
|
+
import copy
|
2
|
+
import inspect
|
3
|
+
import logging
|
4
|
+
import typing as tp
|
5
|
+
import cachetools
|
6
|
+
|
7
|
+
from fastapi import FastAPI, routing
|
8
|
+
from starlette.requests import Request
|
9
|
+
from starlette.responses import Response
|
10
|
+
from starlette.routing import Mount, get_route_path
|
11
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
12
|
+
|
13
|
+
from .controller import Controller
|
14
|
+
from .depends import BaseCacheConfigDepends, CacheConfig, CacheDropConfig
|
15
|
+
from .schemas import RouteInfo
|
16
|
+
from .storages import BaseStorage, InMemoryStorage
|
17
|
+
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
def get_app_routes(app: FastAPI) -> tp.List[routing.APIRoute]:
|
22
|
+
"""Gets all routes from FastAPI application.
|
23
|
+
|
24
|
+
Recursively traverses all application routers and collects their routes.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
app: FastAPI application
|
28
|
+
|
29
|
+
Returns:
|
30
|
+
List of all application routes
|
31
|
+
"""
|
32
|
+
routes = []
|
33
|
+
|
34
|
+
# Get routes from main application router
|
35
|
+
routes.extend(get_routes(app.router))
|
36
|
+
|
37
|
+
# Traverse all nested routers
|
38
|
+
for route in app.router.routes:
|
39
|
+
if isinstance(route, Mount):
|
40
|
+
if isinstance(route.app, routing.APIRouter):
|
41
|
+
routes.extend(get_routes(route.app))
|
42
|
+
|
43
|
+
return routes
|
44
|
+
|
45
|
+
|
46
|
+
def get_routes(router: routing.APIRouter) -> list[routing.APIRoute]:
|
47
|
+
"""Recursively gets all routes from router.
|
48
|
+
|
49
|
+
Traverses all routes in router and its sub-routers, collecting them into a single list.
|
50
|
+
|
51
|
+
Args:
|
52
|
+
router: APIRouter to traverse
|
53
|
+
|
54
|
+
Returns:
|
55
|
+
List of all routes from router and its sub-routers
|
56
|
+
"""
|
57
|
+
routes = []
|
58
|
+
|
59
|
+
# Get all routes from current router
|
60
|
+
for route in router.routes:
|
61
|
+
if isinstance(route, routing.APIRoute):
|
62
|
+
routes.append(route)
|
63
|
+
elif isinstance(route, Mount):
|
64
|
+
# Recursively traverse sub-routers
|
65
|
+
if isinstance(route.app, routing.APIRouter):
|
66
|
+
routes.extend(get_routes(route.app))
|
67
|
+
|
68
|
+
return routes
|
69
|
+
|
70
|
+
|
71
|
+
async def send_with_callbacks(
|
72
|
+
app: ASGIApp,
|
73
|
+
scope: Scope,
|
74
|
+
receive: Receive,
|
75
|
+
send: Send,
|
76
|
+
on_response_ready: tp.Callable[[Response], tp.Awaitable[None]] | None = None,
|
77
|
+
) -> None:
|
78
|
+
response_holder: tp.Dict[str, tp.Any] = {}
|
79
|
+
|
80
|
+
async def response_builder(message: tp.Dict[str, tp.Any]) -> None:
|
81
|
+
"""Wrapper for intercepting and saving response."""
|
82
|
+
if message["type"] == "http.response.start":
|
83
|
+
response_holder["status"] = message["status"]
|
84
|
+
|
85
|
+
message.get("headers", []).append(
|
86
|
+
("X-Cache-Status".encode(), "MISS".encode())
|
87
|
+
)
|
88
|
+
response_holder["headers"] = [
|
89
|
+
(k.decode(), v.decode()) for k, v in message.get("headers", [])
|
90
|
+
]
|
91
|
+
|
92
|
+
response_holder["body"] = b""
|
93
|
+
elif message["type"] == "http.response.body":
|
94
|
+
body = message.get("body", b"")
|
95
|
+
response_holder["body"] += body
|
96
|
+
|
97
|
+
# If this is the last chunk, cache the response
|
98
|
+
if not message.get("more_body", False):
|
99
|
+
response = Response(
|
100
|
+
content=response_holder["body"],
|
101
|
+
status_code=response_holder["status"],
|
102
|
+
headers=dict(response_holder["headers"]),
|
103
|
+
)
|
104
|
+
|
105
|
+
# Call callback with ready response
|
106
|
+
if on_response_ready:
|
107
|
+
await on_response_ready(response)
|
108
|
+
|
109
|
+
# Pass event further
|
110
|
+
await send(message)
|
111
|
+
|
112
|
+
await app(scope, receive, response_builder)
|
113
|
+
|
114
|
+
|
115
|
+
class FastCacheMiddleware:
|
116
|
+
"""Middleware for caching responses in ASGI applications.
|
117
|
+
|
118
|
+
Route resolution approach:
|
119
|
+
1. Analyzes all routes and their dependencies at startup
|
120
|
+
2. Finds corresponding route by path and method on request
|
121
|
+
3. Extracts cache configuration from route dependencies
|
122
|
+
4. Performs standard caching/invalidation logic
|
123
|
+
|
124
|
+
Advantages:
|
125
|
+
- Pre-route analysis - fast configuration lookup
|
126
|
+
- Support for all FastAPI dependencies
|
127
|
+
- Flexible cache management at route level
|
128
|
+
- Efficient cache invalidation
|
129
|
+
|
130
|
+
Args:
|
131
|
+
app: ASGI application to wrap
|
132
|
+
storage: Cache storage (default InMemoryStorage)
|
133
|
+
controller: Controller for managing caching logic
|
134
|
+
"""
|
135
|
+
|
136
|
+
def __init__(
|
137
|
+
self,
|
138
|
+
app: ASGIApp,
|
139
|
+
storage: tp.Optional[BaseStorage] = None,
|
140
|
+
controller: tp.Optional[Controller] = None,
|
141
|
+
) -> None:
|
142
|
+
self.app = app
|
143
|
+
self.storage = storage or InMemoryStorage()
|
144
|
+
self.controller = controller or Controller()
|
145
|
+
|
146
|
+
self._routes_info: list[RouteInfo] = []
|
147
|
+
|
148
|
+
def _extract_routes_info(self, routes: list[routing.APIRoute]) -> list[RouteInfo]:
|
149
|
+
"""Recursively extracts route information and their dependencies.
|
150
|
+
|
151
|
+
Args:
|
152
|
+
routes: List of routes to analyze
|
153
|
+
"""
|
154
|
+
routes_info = []
|
155
|
+
for route in routes:
|
156
|
+
(
|
157
|
+
cache_config,
|
158
|
+
cache_drop_config,
|
159
|
+
) = self._extract_cache_configs_from_route(route)
|
160
|
+
|
161
|
+
if cache_config or cache_drop_config:
|
162
|
+
route_info = RouteInfo(
|
163
|
+
route=route,
|
164
|
+
cache_config=cache_config,
|
165
|
+
cache_drop_config=cache_drop_config,
|
166
|
+
)
|
167
|
+
routes_info.append(route_info)
|
168
|
+
|
169
|
+
return routes_info
|
170
|
+
|
171
|
+
def _extract_cache_configs_from_route(
|
172
|
+
self, route: routing.APIRoute
|
173
|
+
) -> tp.Tuple[CacheConfig | None, CacheDropConfig | None]:
|
174
|
+
"""Extracts cache configurations from route dependencies.
|
175
|
+
|
176
|
+
Args:
|
177
|
+
route: Route to analyze
|
178
|
+
|
179
|
+
Returns:
|
180
|
+
Tuple with CacheConfig and CacheDropConfig (if found)
|
181
|
+
"""
|
182
|
+
cache_config = None
|
183
|
+
cache_drop_config = None
|
184
|
+
|
185
|
+
endpoint = getattr(route, "endpoint", None)
|
186
|
+
if not endpoint:
|
187
|
+
return None, None
|
188
|
+
|
189
|
+
# Analyze dependencies if they exist
|
190
|
+
for dependency in getattr(route, "dependencies", []):
|
191
|
+
if isinstance(dependency, BaseCacheConfigDepends):
|
192
|
+
# need to make a copy, as dependency can be destroyed
|
193
|
+
dependency = copy.deepcopy(dependency)
|
194
|
+
if isinstance(dependency, CacheConfig):
|
195
|
+
cache_config = dependency
|
196
|
+
elif isinstance(dependency, CacheDropConfig):
|
197
|
+
cache_drop_config = dependency
|
198
|
+
continue
|
199
|
+
|
200
|
+
return cache_config, cache_drop_config
|
201
|
+
|
202
|
+
@cachetools.cached(
|
203
|
+
cache=cachetools.LRUCache(maxsize=10**3),
|
204
|
+
key=lambda _, request, __: get_route_path(request.scope),
|
205
|
+
)
|
206
|
+
def _find_matching_route(
|
207
|
+
self, request: Request, routes_info: list[RouteInfo]
|
208
|
+
) -> tp.Optional[RouteInfo]:
|
209
|
+
"""Finds route matching the request.
|
210
|
+
|
211
|
+
Args:
|
212
|
+
request: HTTP request
|
213
|
+
|
214
|
+
Returns:
|
215
|
+
RouteInfo if matching route found, otherwise None
|
216
|
+
"""
|
217
|
+
for route_info in routes_info:
|
218
|
+
match_mode, _ = route_info.route.matches(request.scope)
|
219
|
+
if match_mode == routing.Match.FULL:
|
220
|
+
return route_info
|
221
|
+
|
222
|
+
return
|
223
|
+
|
224
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
225
|
+
if scope["type"] != "http":
|
226
|
+
await self.app(scope, receive, send)
|
227
|
+
return
|
228
|
+
|
229
|
+
if not self._routes_info:
|
230
|
+
app_routes = get_app_routes(scope["app"])
|
231
|
+
self._routes_info = self._extract_routes_info(app_routes)
|
232
|
+
|
233
|
+
request = Request(scope, receive)
|
234
|
+
|
235
|
+
# Find matching route
|
236
|
+
route_info = self._find_matching_route(request, self._routes_info)
|
237
|
+
if not route_info:
|
238
|
+
await self.app(scope, receive, send)
|
239
|
+
return
|
240
|
+
|
241
|
+
# Handle invalidation if specified
|
242
|
+
if cc := route_info.cache_drop_config:
|
243
|
+
await self.controller.invalidate_cache(cc, storage=self.storage)
|
244
|
+
|
245
|
+
# Handle caching if config exists
|
246
|
+
if route_info.cache_config:
|
247
|
+
await self._handle_cache_request(route_info, request, scope, receive, send)
|
248
|
+
return
|
249
|
+
|
250
|
+
# Execute original request
|
251
|
+
await self.app(scope, receive, send)
|
252
|
+
|
253
|
+
async def _handle_cache_request(
|
254
|
+
self,
|
255
|
+
route_info: RouteInfo,
|
256
|
+
request: Request,
|
257
|
+
scope: Scope,
|
258
|
+
receive: Receive,
|
259
|
+
send: Send,
|
260
|
+
) -> None:
|
261
|
+
"""Handles request with caching.
|
262
|
+
|
263
|
+
Args:
|
264
|
+
route_info: Route information
|
265
|
+
request: HTTP request
|
266
|
+
scope: ASGI scope
|
267
|
+
receive: ASGI receive callable
|
268
|
+
send: ASGI send callable
|
269
|
+
"""
|
270
|
+
cache_config = route_info.cache_config
|
271
|
+
if not cache_config:
|
272
|
+
await self.app(scope, receive, send)
|
273
|
+
return
|
274
|
+
|
275
|
+
if not await self.controller.is_cachable_request(request):
|
276
|
+
await self.app(scope, receive, send)
|
277
|
+
return
|
278
|
+
|
279
|
+
cache_key = await self.controller.generate_cache_key(request, cache_config)
|
280
|
+
|
281
|
+
cached_response = await self.controller.get_cached_response(
|
282
|
+
cache_key, self.storage
|
283
|
+
)
|
284
|
+
if cached_response is not None:
|
285
|
+
logger.debug("Returning cached response for key: %s", cache_key)
|
286
|
+
await cached_response(scope, receive, send)
|
287
|
+
return
|
288
|
+
|
289
|
+
# Cache not found - execute request and cache result
|
290
|
+
await send_with_callbacks(
|
291
|
+
self.app,
|
292
|
+
scope,
|
293
|
+
receive,
|
294
|
+
send,
|
295
|
+
lambda response: self.controller.cache_response(
|
296
|
+
cache_key, request, response, self.storage, cache_config.max_age
|
297
|
+
),
|
298
|
+
)
|
fast_cache_middleware/schemas.py
CHANGED
@@ -1,21 +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())
|
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())
|