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.
@@ -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
- 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
+ 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
+ )
@@ -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())