fast-cache-middleware 0.0.1__py3-none-any.whl → 0.0.3__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.
@@ -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,306 @@
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
- )
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
+ def _build_scope_hash_key(scope: Scope) -> str:
116
+ path = get_route_path(scope)
117
+ method = scope["method"].upper()
118
+ return f"{path}/{method}"
119
+
120
+
121
+ class FastCacheMiddleware:
122
+ """Middleware for caching responses in ASGI applications.
123
+
124
+ Route resolution approach:
125
+ 1. Analyzes all routes and their dependencies at startup
126
+ 2. Finds corresponding route by path and method on request
127
+ 3. Extracts cache configuration from route dependencies
128
+ 4. Performs standard caching/invalidation logic
129
+
130
+ Advantages:
131
+ - Pre-route analysis - fast configuration lookup
132
+ - Support for all FastAPI dependencies
133
+ - Flexible cache management at route level
134
+ - Efficient cache invalidation
135
+
136
+ Args:
137
+ app: ASGI application to wrap
138
+ storage: Cache storage (default InMemoryStorage)
139
+ controller: Controller for managing caching logic
140
+ """
141
+
142
+ def __init__(
143
+ self,
144
+ app: ASGIApp,
145
+ storage: tp.Optional[BaseStorage] = None,
146
+ controller: tp.Optional[Controller] = None,
147
+ ) -> None:
148
+ self.app = app
149
+ self.storage = storage or InMemoryStorage()
150
+ self.controller = controller or Controller()
151
+
152
+ self._routes_info: list[RouteInfo] = []
153
+
154
+ def _extract_routes_info(self, routes: list[routing.APIRoute]) -> list[RouteInfo]:
155
+ """Recursively extracts route information and their dependencies.
156
+
157
+ Args:
158
+ routes: List of routes to analyze
159
+ """
160
+ routes_info = []
161
+ for route in routes:
162
+ (
163
+ cache_config,
164
+ cache_drop_config,
165
+ ) = self._extract_cache_configs_from_route(route)
166
+
167
+ if cache_config or cache_drop_config:
168
+ route_info = RouteInfo(
169
+ route=route,
170
+ cache_config=cache_config,
171
+ cache_drop_config=cache_drop_config,
172
+ )
173
+ routes_info.append(route_info)
174
+
175
+ return routes_info
176
+
177
+ def _extract_cache_configs_from_route(
178
+ self, route: routing.APIRoute
179
+ ) -> tp.Tuple[CacheConfig | None, CacheDropConfig | None]:
180
+ """Extracts cache configurations from route dependencies.
181
+
182
+ Args:
183
+ route: Route to analyze
184
+
185
+ Returns:
186
+ Tuple with CacheConfig and CacheDropConfig (if found)
187
+ """
188
+ cache_config = None
189
+ cache_drop_config = None
190
+
191
+ endpoint = getattr(route, "endpoint", None)
192
+ if not endpoint:
193
+ return None, None
194
+
195
+ # Analyze dependencies if they exist
196
+ for dependency in getattr(route, "dependencies", []):
197
+ if isinstance(dependency, BaseCacheConfigDepends):
198
+ # need to make a copy, as dependency can be destroyed
199
+ dependency = copy.deepcopy(dependency)
200
+ if isinstance(dependency, CacheConfig):
201
+ cache_config = dependency
202
+ elif isinstance(dependency, CacheDropConfig):
203
+ cache_drop_config = dependency
204
+ continue
205
+
206
+ return cache_config, cache_drop_config
207
+
208
+ @cachetools.cached(
209
+ cache=cachetools.LRUCache(maxsize=10**3),
210
+ key=lambda _, request, __: _build_scope_hash_key(request.scope),
211
+ )
212
+ def _find_matching_route(
213
+ self, request: Request, routes_info: list[RouteInfo]
214
+ ) -> tp.Optional[RouteInfo]:
215
+ """Finds route matching the request.
216
+
217
+ Args:
218
+ request: HTTP request
219
+
220
+ Returns:
221
+ RouteInfo if matching route found, otherwise None
222
+ """
223
+ for route_info in routes_info:
224
+ if request.method not in route_info.methods:
225
+ continue
226
+ match_mode, _ = route_info.route.matches(request.scope)
227
+ if match_mode == routing.Match.FULL:
228
+ return route_info
229
+
230
+ return
231
+
232
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
233
+ if scope["type"] != "http":
234
+ await self.app(scope, receive, send)
235
+ return
236
+
237
+ if not self._routes_info:
238
+ app_routes = get_app_routes(scope["app"])
239
+ self._routes_info = self._extract_routes_info(app_routes)
240
+
241
+ request = Request(scope, receive)
242
+
243
+ # Find matching route
244
+ route_info = self._find_matching_route(request, self._routes_info)
245
+ if not route_info:
246
+ await self.app(scope, receive, send)
247
+ return
248
+
249
+ # Handle invalidation if specified
250
+ if cc := route_info.cache_drop_config:
251
+ await self.controller.invalidate_cache(cc, storage=self.storage)
252
+
253
+ # Handle caching if config exists
254
+ if route_info.cache_config:
255
+ await self._handle_cache_request(route_info, request, scope, receive, send)
256
+ return
257
+
258
+ # Execute original request
259
+ await self.app(scope, receive, send)
260
+
261
+ async def _handle_cache_request(
262
+ self,
263
+ route_info: RouteInfo,
264
+ request: Request,
265
+ scope: Scope,
266
+ receive: Receive,
267
+ send: Send,
268
+ ) -> None:
269
+ """Handles request with caching.
270
+
271
+ Args:
272
+ route_info: Route information
273
+ request: HTTP request
274
+ scope: ASGI scope
275
+ receive: ASGI receive callable
276
+ send: ASGI send callable
277
+ """
278
+ cache_config = route_info.cache_config
279
+ if not cache_config:
280
+ await self.app(scope, receive, send)
281
+ return
282
+
283
+ if not await self.controller.is_cachable_request(request):
284
+ await self.app(scope, receive, send)
285
+ return
286
+
287
+ cache_key = await self.controller.generate_cache_key(request, cache_config)
288
+
289
+ cached_response = await self.controller.get_cached_response(
290
+ cache_key, self.storage
291
+ )
292
+ if cached_response is not None:
293
+ logger.debug("Returning cached response for key: %s", cache_key)
294
+ await cached_response(scope, receive, send)
295
+ return
296
+
297
+ # Cache not found - execute request and cache result
298
+ await send_with_callbacks(
299
+ self.app,
300
+ scope,
301
+ receive,
302
+ send,
303
+ lambda response: self.controller.cache_response(
304
+ cache_key, request, response, self.storage, cache_config.max_age
305
+ ),
306
+ )