fastapi-caching-route 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_caching_route/__init__.py +6 -0
- fastapi_caching_route/_version.py +1 -0
- fastapi_caching_route/_version.pyi +1 -0
- fastapi_caching_route/main.py +590 -0
- fastapi_caching_route/py.typed +0 -0
- fastapi_caching_route-0.6.0.dist-info/METADATA +250 -0
- fastapi_caching_route-0.6.0.dist-info/RECORD +9 -0
- fastapi_caching_route-0.6.0.dist-info/WHEEL +4 -0
- fastapi_caching_route-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version = "0.6.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version: str
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""Implementation for FastAPI Caching Route."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from contextlib import AsyncExitStack
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from hashlib import sha256
|
|
9
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal, ParamSpec, TypedDict, TypeVar
|
|
10
|
+
|
|
11
|
+
from fastapi import Request, Response
|
|
12
|
+
from fastapi.dependencies.models import Dependant
|
|
13
|
+
from fastapi.dependencies.utils import get_dependant, solve_dependencies
|
|
14
|
+
from fastapi.routing import APIRoute
|
|
15
|
+
from starlette.datastructures import MutableHeaders
|
|
16
|
+
from starlette.responses import StreamingResponse
|
|
17
|
+
from starlette.status import HTTP_200_OK, HTTP_304_NOT_MODIFIED
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import (
|
|
22
|
+
AsyncGenerator,
|
|
23
|
+
Awaitable,
|
|
24
|
+
Callable,
|
|
25
|
+
Coroutine,
|
|
26
|
+
Iterable,
|
|
27
|
+
Sequence,
|
|
28
|
+
Set as AbstractSet,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from aiocache import BaseCache
|
|
32
|
+
from fastapi.params import Depends
|
|
33
|
+
from typing_extensions import Buffer, Doc, NotRequired
|
|
34
|
+
|
|
35
|
+
KeyBuilder = Callable[[Request], str]
|
|
36
|
+
RouteHandler = Callable[[Request], Coroutine[Any, Any, Response]]
|
|
37
|
+
|
|
38
|
+
class CacheParamsBase(TypedDict):
|
|
39
|
+
"""Cache parameters to be passed to aiocache."""
|
|
40
|
+
|
|
41
|
+
namespace: NotRequired[str | None]
|
|
42
|
+
ttl: NotRequired[float]
|
|
43
|
+
|
|
44
|
+
class CacheParams(CacheParamsBase):
|
|
45
|
+
"""Cache parameters for a specific endpoint."""
|
|
46
|
+
|
|
47
|
+
key_builder: NotRequired[KeyBuilder]
|
|
48
|
+
dependencies: NotRequired[Sequence[Depends]]
|
|
49
|
+
vary_headers: NotRequired[Sequence[str]]
|
|
50
|
+
|
|
51
|
+
class CachedResponse(TypedDict):
|
|
52
|
+
"""Response data to be stored in cache."""
|
|
53
|
+
|
|
54
|
+
content: Buffer
|
|
55
|
+
media_type: str | None
|
|
56
|
+
raw_headers: list[tuple[bytes, bytes]]
|
|
57
|
+
status_code: int
|
|
58
|
+
|
|
59
|
+
_T = TypeVar('_T')
|
|
60
|
+
_P = ParamSpec('_P')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_CACHE_CONFIG = '__fastapi_caching_route_87233d36__'
|
|
64
|
+
|
|
65
|
+
DEFAULT_ACCEPTED_STATUS_CODES = frozenset({HTTP_200_OK})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class _CacheConfig:
|
|
70
|
+
cache: FastAPICache
|
|
71
|
+
key_builder: KeyBuilder | None
|
|
72
|
+
early_dependencies: Sequence[Depends]
|
|
73
|
+
vary_headers: tuple[str, ...]
|
|
74
|
+
params: CacheParamsBase
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class _CacheRequest:
|
|
79
|
+
request: Request
|
|
80
|
+
cache: FastAPICache
|
|
81
|
+
cache_key: str
|
|
82
|
+
caching_params: CacheParamsBase
|
|
83
|
+
namespace: str | None
|
|
84
|
+
vary_headers: tuple[str, ...]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FastAPICache:
|
|
88
|
+
"""Manages cached routes.
|
|
89
|
+
|
|
90
|
+
## Example
|
|
91
|
+
|
|
92
|
+
```py
|
|
93
|
+
from aiocache import SimpleMemoryCache
|
|
94
|
+
from fastapi import APIRouter, FastAPI
|
|
95
|
+
from fastapi_caching_route import CachingRoute, FastAPICache
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
router = APIRouter(route_class=CachingRoute)
|
|
99
|
+
cache = FastAPICache(SimpleMemoryCache())
|
|
100
|
+
|
|
101
|
+
@cache()
|
|
102
|
+
@router.get('/')
|
|
103
|
+
def cached() -> str:
|
|
104
|
+
return 'Hello, World!'
|
|
105
|
+
|
|
106
|
+
app = FastAPI()
|
|
107
|
+
app.include_router(router)
|
|
108
|
+
```
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
__slots__ = (
|
|
112
|
+
'_cache_header',
|
|
113
|
+
'_cache_header_hit',
|
|
114
|
+
'_cache_header_miss',
|
|
115
|
+
'_concat_namespace',
|
|
116
|
+
'_inner',
|
|
117
|
+
'accepted_status_codes',
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
cache: Annotated[BaseCache, Doc('aiocache instance to perform caching.')],
|
|
123
|
+
*,
|
|
124
|
+
namespace_policy: Annotated[
|
|
125
|
+
Literal['concat', 'replace'],
|
|
126
|
+
Doc(
|
|
127
|
+
"""How to process namespaces passed to the decorator.
|
|
128
|
+
|
|
129
|
+
## concat (default)
|
|
130
|
+
|
|
131
|
+
Add to the root (passed to the aiocache instance) namespace.
|
|
132
|
+
|
|
133
|
+
```py
|
|
134
|
+
cache = FastAPICache(RedisCache(namespace='cache'))
|
|
135
|
+
|
|
136
|
+
# resulting namespace is 'cache:user'
|
|
137
|
+
@cache(namespace='user')
|
|
138
|
+
@router.get('/{user_id}')
|
|
139
|
+
async def get_user(user_id: str):
|
|
140
|
+
...
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## replace
|
|
144
|
+
|
|
145
|
+
Replace the root namespace.
|
|
146
|
+
|
|
147
|
+
```py
|
|
148
|
+
cache = FastAPICache(RedisCache(namespace='cache'), namespace_policy='replace')
|
|
149
|
+
|
|
150
|
+
# resulting namespace is 'user'
|
|
151
|
+
@cache(namespace='user')
|
|
152
|
+
@router.get('/{user_id}')
|
|
153
|
+
async def get_user(user_id: str):
|
|
154
|
+
...
|
|
155
|
+
```
|
|
156
|
+
""",
|
|
157
|
+
),
|
|
158
|
+
] = 'concat',
|
|
159
|
+
cache_header: str = 'X-Cache',
|
|
160
|
+
cache_header_hit: str = 'HIT',
|
|
161
|
+
cache_header_miss: str = 'MISS',
|
|
162
|
+
accepted_status_codes: Annotated[
|
|
163
|
+
AbstractSet[int] | Iterable[int],
|
|
164
|
+
Doc('Only cache responses with these HTTP status codes.'),
|
|
165
|
+
] = DEFAULT_ACCEPTED_STATUS_CODES,
|
|
166
|
+
) -> None:
|
|
167
|
+
self._inner = cache
|
|
168
|
+
self._concat_namespace = namespace_policy == 'concat'
|
|
169
|
+
self._cache_header = cache_header
|
|
170
|
+
self._cache_header_hit = cache_header_hit
|
|
171
|
+
self._cache_header_miss = cache_header_miss
|
|
172
|
+
self.accepted_status_codes = frozenset(accepted_status_codes)
|
|
173
|
+
|
|
174
|
+
def __call__(
|
|
175
|
+
self,
|
|
176
|
+
*,
|
|
177
|
+
key_builder: KeyBuilder | None = None,
|
|
178
|
+
dependencies: Sequence[Depends] = (),
|
|
179
|
+
vary_headers: Sequence[str] = (),
|
|
180
|
+
namespace: str | None = None,
|
|
181
|
+
ttl: float | None = None,
|
|
182
|
+
) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
|
|
183
|
+
"""Decorate caching route.
|
|
184
|
+
|
|
185
|
+
Marks the endpoint for caching by :class:`CachingRoute`.
|
|
186
|
+
|
|
187
|
+
```py hl_lines="3"
|
|
188
|
+
cache = FastAPICache(SimpleMemoryCache())
|
|
189
|
+
|
|
190
|
+
@cache()
|
|
191
|
+
@router.get('/')
|
|
192
|
+
def cached() -> str:
|
|
193
|
+
...
|
|
194
|
+
```
|
|
195
|
+
"""
|
|
196
|
+
params: CacheParamsBase = {}
|
|
197
|
+
if namespace is not None:
|
|
198
|
+
params['namespace'] = namespace
|
|
199
|
+
if ttl is not None:
|
|
200
|
+
params['ttl'] = ttl
|
|
201
|
+
|
|
202
|
+
def decorator(endpoint: Callable[_P, _T]) -> Callable[_P, _T]:
|
|
203
|
+
config = _CacheConfig(
|
|
204
|
+
cache=self,
|
|
205
|
+
key_builder=key_builder,
|
|
206
|
+
early_dependencies=tuple(dependencies),
|
|
207
|
+
vary_headers=_normalize_header_names(vary_headers),
|
|
208
|
+
params=params,
|
|
209
|
+
)
|
|
210
|
+
setattr(endpoint, _CACHE_CONFIG, config)
|
|
211
|
+
return endpoint
|
|
212
|
+
|
|
213
|
+
return decorator
|
|
214
|
+
|
|
215
|
+
def get_cached(
|
|
216
|
+
self,
|
|
217
|
+
cache_key: str,
|
|
218
|
+
namespace: str | None = None,
|
|
219
|
+
) -> Awaitable[CachedResponse | None]:
|
|
220
|
+
"""Get cached response.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Cached response.
|
|
224
|
+
"""
|
|
225
|
+
return self._inner.get(cache_key, namespace=namespace)
|
|
226
|
+
|
|
227
|
+
def set_cached(
|
|
228
|
+
self,
|
|
229
|
+
cache_key: str,
|
|
230
|
+
value: CachedResponse,
|
|
231
|
+
caching_params: CacheParamsBase,
|
|
232
|
+
) -> Awaitable[bool]:
|
|
233
|
+
"""Set cached response.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
`True` if the value was set.
|
|
237
|
+
"""
|
|
238
|
+
return self._inner.set(cache_key, value, **caching_params)
|
|
239
|
+
|
|
240
|
+
def invalidate_cached(
|
|
241
|
+
self,
|
|
242
|
+
cache_key: str,
|
|
243
|
+
namespace: str | None = None,
|
|
244
|
+
) -> Annotated[Awaitable[int], Doc('Number of deleted keys.')]:
|
|
245
|
+
"""Delete cached response.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Number of deleted keys.
|
|
249
|
+
"""
|
|
250
|
+
return self._inner.delete(cache_key, namespace)
|
|
251
|
+
|
|
252
|
+
def prepare_cache_params(self, cache_params: CacheParamsBase) -> CacheParamsBase:
|
|
253
|
+
"""Prepare cache backend parameters for a request."""
|
|
254
|
+
caching_params = cache_params.copy()
|
|
255
|
+
namespace = caching_params.get('namespace', None)
|
|
256
|
+
root = self._inner.namespace
|
|
257
|
+
if self._concat_namespace and root and namespace:
|
|
258
|
+
caching_params['namespace'] = f'{root}:{namespace}'
|
|
259
|
+
return caching_params
|
|
260
|
+
|
|
261
|
+
def set_cache_header(self, headers: MutableHeaders | dict[str, str], *, hit: bool) -> None:
|
|
262
|
+
"""Set a cache status header."""
|
|
263
|
+
headers[self._cache_header] = self._cache_header_hit if hit else self._cache_header_miss
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class CachingRoute(APIRoute):
|
|
267
|
+
"""FastAPI route to perform caching.
|
|
268
|
+
|
|
269
|
+
Intended for use with fastapi.APIRouter.
|
|
270
|
+
|
|
271
|
+
```py hl_lines="4"
|
|
272
|
+
from fastapi import APIRouter
|
|
273
|
+
from fastapi_caching_route import CachingRoute
|
|
274
|
+
|
|
275
|
+
router = APIRouter(route_class=CachingRoute)
|
|
276
|
+
```
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def get_route_handler(self) -> RouteHandler: # noqa: D102
|
|
280
|
+
return _CachingRouteHandler(
|
|
281
|
+
original_handler=super().get_route_handler(),
|
|
282
|
+
path=self.path,
|
|
283
|
+
endpoint=self.endpoint,
|
|
284
|
+
query_params=self.dependant.query_params,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass(slots=True, kw_only=True)
|
|
289
|
+
class _CachingRouteHandler:
|
|
290
|
+
original_handler: RouteHandler
|
|
291
|
+
path: str
|
|
292
|
+
endpoint: Any
|
|
293
|
+
query_params: Sequence[Any]
|
|
294
|
+
early_dependant: Dependant | None | Literal[False] = False
|
|
295
|
+
default_key_builder: KeyBuilder | None = None
|
|
296
|
+
|
|
297
|
+
async def __call__(self, request: Request) -> Response:
|
|
298
|
+
config = getattr(self.endpoint, _CACHE_CONFIG, None)
|
|
299
|
+
if not isinstance(config, _CacheConfig):
|
|
300
|
+
return await self.original_handler(request)
|
|
301
|
+
|
|
302
|
+
if not await self._early_dependencies_solved(request, config):
|
|
303
|
+
return await self.original_handler(request)
|
|
304
|
+
|
|
305
|
+
cache_params = self._prepare_cache_request(request, config)
|
|
306
|
+
if cached_response := await self._get_cached_response(cache_params):
|
|
307
|
+
return cached_response
|
|
308
|
+
|
|
309
|
+
response = await self.original_handler(request)
|
|
310
|
+
return await self._cache_response(response, cache_params)
|
|
311
|
+
|
|
312
|
+
async def _early_dependencies_solved(self, request: Request, config: _CacheConfig) -> bool:
|
|
313
|
+
if self.early_dependant is False:
|
|
314
|
+
self.early_dependant = _build_early_dependant(self.path, config)
|
|
315
|
+
if self.early_dependant is None:
|
|
316
|
+
return True
|
|
317
|
+
async with AsyncExitStack() as async_exit_stack:
|
|
318
|
+
solved_dependency = await solve_dependencies(
|
|
319
|
+
request=request,
|
|
320
|
+
dependant=self.early_dependant,
|
|
321
|
+
async_exit_stack=async_exit_stack,
|
|
322
|
+
embed_body_fields=False,
|
|
323
|
+
)
|
|
324
|
+
return not solved_dependency.errors
|
|
325
|
+
|
|
326
|
+
def _prepare_cache_request(self, request: Request, config: _CacheConfig) -> _CacheRequest:
|
|
327
|
+
cache = config.cache
|
|
328
|
+
caching_params = cache.prepare_cache_params(config.params)
|
|
329
|
+
key_builder = config.key_builder or self._get_default_key_builder(config)
|
|
330
|
+
return _CacheRequest(
|
|
331
|
+
request=request,
|
|
332
|
+
cache=cache,
|
|
333
|
+
cache_key=key_builder(request),
|
|
334
|
+
caching_params=caching_params,
|
|
335
|
+
namespace=caching_params.get('namespace', None),
|
|
336
|
+
vary_headers=config.vary_headers,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def _get_default_key_builder(self, config: _CacheConfig) -> KeyBuilder:
|
|
340
|
+
if self.default_key_builder is None:
|
|
341
|
+
self.default_key_builder = _key_builder_factory(self.query_params, config.vary_headers)
|
|
342
|
+
return self.default_key_builder
|
|
343
|
+
|
|
344
|
+
async def _get_cached_response(self, cache_request: _CacheRequest) -> Response | None:
|
|
345
|
+
cached = await cache_request.cache.get_cached(
|
|
346
|
+
cache_request.cache_key,
|
|
347
|
+
cache_request.namespace,
|
|
348
|
+
)
|
|
349
|
+
if cached is None:
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
headers = MutableHeaders(raw=cached['raw_headers'])
|
|
353
|
+
cache_request.cache.set_cache_header(headers, hit=True)
|
|
354
|
+
return _build_cached_response(cache_request.request, cached, headers)
|
|
355
|
+
|
|
356
|
+
async def _cache_response(self, response: Response, cache_request: _CacheRequest) -> Response:
|
|
357
|
+
cache = cache_request.cache
|
|
358
|
+
if response.status_code not in cache.accepted_status_codes:
|
|
359
|
+
return response
|
|
360
|
+
|
|
361
|
+
_add_vary_header(response.headers, cache_request.vary_headers)
|
|
362
|
+
if _get_vary_headers(response.headers) is None:
|
|
363
|
+
return response
|
|
364
|
+
|
|
365
|
+
if isinstance(response, StreamingResponse):
|
|
366
|
+
cached, response = await _cache_streaming_response(response)
|
|
367
|
+
else:
|
|
368
|
+
_ensure_etag(response.headers, response.body)
|
|
369
|
+
cached: CachedResponse = {
|
|
370
|
+
'content': response.body,
|
|
371
|
+
'media_type': response.media_type,
|
|
372
|
+
'raw_headers': list(response.raw_headers),
|
|
373
|
+
'status_code': response.status_code,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
await cache.set_cached(cache_request.cache_key, cached, cache_request.caching_params)
|
|
377
|
+
cache.set_cache_header(response.headers, hit=False)
|
|
378
|
+
return response
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _build_early_dependant(path: str, config: _CacheConfig) -> Dependant | None:
|
|
382
|
+
dependencies = [
|
|
383
|
+
get_dependant(
|
|
384
|
+
path=path,
|
|
385
|
+
call=dependency.dependency,
|
|
386
|
+
use_cache=dependency.use_cache,
|
|
387
|
+
)
|
|
388
|
+
for dependency in config.early_dependencies
|
|
389
|
+
if dependency.dependency
|
|
390
|
+
]
|
|
391
|
+
if not dependencies:
|
|
392
|
+
return None
|
|
393
|
+
return Dependant(dependencies=dependencies)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _key_builder_factory(params: Sequence[Any], vary_headers: Sequence[str]) -> KeyBuilder:
|
|
397
|
+
params_ = []
|
|
398
|
+
for param in sorted(params, key=lambda p: p.alias or p.name):
|
|
399
|
+
default = '' if param.field_info.is_required() else param.default
|
|
400
|
+
if isinstance(default, list):
|
|
401
|
+
default_values = tuple(str(value) for value in default)
|
|
402
|
+
else:
|
|
403
|
+
default_values = (default,)
|
|
404
|
+
params_.append((param.alias or param.name, default_values))
|
|
405
|
+
vary_headers = _normalize_header_names(vary_headers)
|
|
406
|
+
|
|
407
|
+
def _impl(request: Request) -> str:
|
|
408
|
+
key = (
|
|
409
|
+
request.method,
|
|
410
|
+
request.scope['path'],
|
|
411
|
+
tuple((k, tuple(request.query_params.getlist(k)) or d) for k, d in params_),
|
|
412
|
+
tuple((h, request.headers.get(h, '')) for h in vary_headers),
|
|
413
|
+
)
|
|
414
|
+
digest = sha256(repr(key).encode('utf-8'), usedforsecurity=False).digest()
|
|
415
|
+
return base64.b64encode(digest).decode()
|
|
416
|
+
|
|
417
|
+
return _impl
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _build_cached_response(
|
|
421
|
+
request: Request,
|
|
422
|
+
cached: CachedResponse,
|
|
423
|
+
headers: MutableHeaders,
|
|
424
|
+
) -> Response:
|
|
425
|
+
if_none_match = request.headers.get('if-none-match', None)
|
|
426
|
+
if if_none_match and _if_none_match_matches(if_none_match, headers.get('etag', '')):
|
|
427
|
+
headers['content-length'] = '0'
|
|
428
|
+
return _build_response(
|
|
429
|
+
content=b'',
|
|
430
|
+
status_code=HTTP_304_NOT_MODIFIED,
|
|
431
|
+
headers=headers,
|
|
432
|
+
media_type=cached['media_type'],
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
return _build_response(
|
|
436
|
+
content=cached['content'],
|
|
437
|
+
status_code=cached['status_code'],
|
|
438
|
+
headers=headers,
|
|
439
|
+
media_type=cached['media_type'],
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _build_response(
|
|
444
|
+
content: Buffer,
|
|
445
|
+
status_code: int,
|
|
446
|
+
headers: MutableHeaders,
|
|
447
|
+
media_type: str | None,
|
|
448
|
+
) -> Response:
|
|
449
|
+
response = Response(content=content, status_code=status_code, media_type=media_type)
|
|
450
|
+
response.raw_headers = headers.raw
|
|
451
|
+
return response
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
async def _cache_streaming_response(
|
|
455
|
+
response: StreamingResponse,
|
|
456
|
+
) -> tuple[CachedResponse, StreamingResponse]:
|
|
457
|
+
status_code = response.status_code
|
|
458
|
+
headers = response.headers
|
|
459
|
+
media_type = response.media_type
|
|
460
|
+
|
|
461
|
+
chunks: list[bytes] = []
|
|
462
|
+
async for chunk in response.body_iterator:
|
|
463
|
+
if isinstance(chunk, str):
|
|
464
|
+
chunks.append(chunk.encode(response.charset))
|
|
465
|
+
else:
|
|
466
|
+
chunks.append(bytes(chunk))
|
|
467
|
+
content = b''.join(chunks)
|
|
468
|
+
|
|
469
|
+
_ensure_etag(headers, content)
|
|
470
|
+
|
|
471
|
+
cached: CachedResponse = {
|
|
472
|
+
'content': content,
|
|
473
|
+
'media_type': media_type,
|
|
474
|
+
'raw_headers': list(response.raw_headers),
|
|
475
|
+
'status_code': status_code,
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
response = StreamingResponse(
|
|
479
|
+
_content_stream(content),
|
|
480
|
+
status_code=status_code,
|
|
481
|
+
headers=headers,
|
|
482
|
+
media_type=media_type,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
return cached, response
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
async def _content_stream(content: bytes) -> AsyncGenerator[bytes, None]:
|
|
489
|
+
b = 0
|
|
490
|
+
for e in range(b, len(content), 10240):
|
|
491
|
+
yield content[b:e]
|
|
492
|
+
b = e
|
|
493
|
+
yield content[b:]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _ensure_etag(headers: MutableHeaders | dict[str, str], content: Buffer) -> None:
|
|
497
|
+
name = 'etag'
|
|
498
|
+
if any(header.lower() == name for header in headers):
|
|
499
|
+
return
|
|
500
|
+
etag = sha256(content, usedforsecurity=False).hexdigest()
|
|
501
|
+
headers[name] = f'"{etag}"'
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _if_none_match_matches(if_none_match: str, etag: str) -> bool:
|
|
505
|
+
if not etag:
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
etag_value = _normalize_etag(etag)
|
|
509
|
+
if etag_value is None:
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
for candidate in _split_etag_header(if_none_match):
|
|
513
|
+
if candidate == '*':
|
|
514
|
+
return True
|
|
515
|
+
if _normalize_etag(candidate) == etag_value:
|
|
516
|
+
return True
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _normalize_etag(etag: str) -> str | None:
|
|
521
|
+
etag = etag.strip()
|
|
522
|
+
weak_prefix_len = 2
|
|
523
|
+
if etag[:weak_prefix_len].upper() == 'W/':
|
|
524
|
+
etag = etag[weak_prefix_len:].lstrip()
|
|
525
|
+
quoted_etag_len = 2
|
|
526
|
+
if len(etag) >= quoted_etag_len and etag[0] == '"' and etag[-1] == '"':
|
|
527
|
+
return etag
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _split_etag_header(header: str) -> list[str]:
|
|
532
|
+
items: list[str] = []
|
|
533
|
+
start = 0
|
|
534
|
+
in_quotes = False
|
|
535
|
+
escaped = False
|
|
536
|
+
for idx, char in enumerate(header):
|
|
537
|
+
if escaped:
|
|
538
|
+
escaped = False
|
|
539
|
+
continue
|
|
540
|
+
if char == '\\' and in_quotes:
|
|
541
|
+
escaped = True
|
|
542
|
+
continue
|
|
543
|
+
if char == '"':
|
|
544
|
+
in_quotes = not in_quotes
|
|
545
|
+
continue
|
|
546
|
+
if char == ',' and not in_quotes:
|
|
547
|
+
items.append(header[start:idx].strip())
|
|
548
|
+
start = idx + 1
|
|
549
|
+
items.append(header[start:].strip())
|
|
550
|
+
return [item for item in items if item]
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _normalize_header_names(headers: Sequence[str]) -> tuple[str, ...]:
|
|
554
|
+
stripped = (header.strip() for header in headers)
|
|
555
|
+
return tuple(dict.fromkeys(header.lower() for header in stripped if header))
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _merge_header_names(*headers: Sequence[str]) -> tuple[str, ...]:
|
|
559
|
+
merged: list[str] = []
|
|
560
|
+
seen: set[str] = set()
|
|
561
|
+
for header_group in headers:
|
|
562
|
+
for header in _normalize_header_names(header_group):
|
|
563
|
+
if header not in seen:
|
|
564
|
+
seen.add(header)
|
|
565
|
+
merged.append(header)
|
|
566
|
+
return tuple(merged)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _get_vary_headers(headers: MutableHeaders | dict[str, str]) -> tuple[str, ...] | None:
|
|
570
|
+
vary = headers.get('vary', '')
|
|
571
|
+
if not vary:
|
|
572
|
+
return ()
|
|
573
|
+
|
|
574
|
+
vary_headers = _normalize_header_names(vary.split(','))
|
|
575
|
+
if '*' in vary_headers:
|
|
576
|
+
return None
|
|
577
|
+
return vary_headers
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _add_vary_header(headers: MutableHeaders | dict[str, str], vary_headers: Sequence[str]) -> None:
|
|
581
|
+
vary_headers = _normalize_header_names(vary_headers)
|
|
582
|
+
if not vary_headers:
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
existing_vary_headers = _get_vary_headers(headers)
|
|
586
|
+
if existing_vary_headers is None:
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
merged = _merge_header_names(existing_vary_headers, vary_headers)
|
|
590
|
+
headers['vary'] = ', '.join(merged)
|
|
File without changes
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-caching-route
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: FastAPI route for efficient caching.
|
|
5
|
+
Project-URL: Homepage, https://github.com/AgroDT/fastapi-caching-route
|
|
6
|
+
Project-URL: Repository, https://github.com/AgroDT/fastapi-caching-route.git
|
|
7
|
+
Project-URL: Issues, https://github.com/AgroDT/fastapi-caching-route/issues
|
|
8
|
+
Project-URL: Documentation, https://agrodt.github.io/fastapi-caching-route/
|
|
9
|
+
Project-URL: Coverage, https://coveralls.io/github/AgroDT/fastapi-caching-route
|
|
10
|
+
Author-email: Petr Tsymbarovich <petr@tsymbarovich.ru>
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: aiocache<1.0,>=0.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# FastAPI Caching Route
|
|
22
|
+
|
|
23
|
+
[](https://github.com/AgroDT/fastapi-caching-route/actions/workflows/pr.yaml)
|
|
24
|
+
[](https://github.com/AgroDT/fastapi-caching-route/actions/workflows/coverage.yaml)
|
|
25
|
+
[](https://coveralls.io/github/AgroDT/fastapi-caching-route)
|
|
26
|
+
[](https://github.com/AgroDT/fastapi-caching-route/actions/workflows/deploy-docs.yaml)
|
|
27
|
+
[](https://pypi.org/project/fastapi-caching-route/)
|
|
28
|
+
|
|
29
|
+
FastAPI route class for response caching before entering the endpoint handler.
|
|
30
|
+
|
|
31
|
+
`fastapi-caching-route` plugs into `fastapi.APIRouter`, stores complete response
|
|
32
|
+
payloads in an `aiocache` backend, and serves cache hits directly from the route
|
|
33
|
+
handler. It is useful for expensive read endpoints where the cache key can be
|
|
34
|
+
derived from the request path, query parameters, or a custom key builder.
|
|
35
|
+
|
|
36
|
+
**⚠️ This project is a proof of concept and is not yet recommended for production use!**
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- Cache regular FastAPI responses and `StreamingResponse` bodies.
|
|
41
|
+
- Return `X-Cache: MISS` for stored responses and `X-Cache: HIT` for cache hits.
|
|
42
|
+
- Generate `ETag` headers for cached responses.
|
|
43
|
+
- Return `304 Not Modified` for matching `If-None-Match` requests.
|
|
44
|
+
- Build default cache keys from the route path and declared query parameters.
|
|
45
|
+
- Provide custom key builders for path parameters or application-specific keys.
|
|
46
|
+
- Include selected request headers in default cache keys for negotiated responses.
|
|
47
|
+
- Run explicitly configured dependencies before cache lookup, for example API key
|
|
48
|
+
checks.
|
|
49
|
+
- Pass `namespace` and `ttl` through to the underlying `aiocache` backend.
|
|
50
|
+
- Manually invalidate cached values through `FastAPICache.invalidate_cached()`.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
uv add fastapi-caching-route
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
pip install fastapi-caching-route
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Install FastAPI and a cache backend that matches your application. For local
|
|
63
|
+
development or tests, `aiocache.SimpleMemoryCache` is enough:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
uv add fastapi aiocache
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
pip install fastapi aiocache
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Basic Usage
|
|
74
|
+
|
|
75
|
+
Use `CachingRoute` as the router route class and decorate endpoints with
|
|
76
|
+
`FastAPICache`.
|
|
77
|
+
|
|
78
|
+
```py
|
|
79
|
+
from aiocache import SimpleMemoryCache
|
|
80
|
+
from fastapi import APIRouter, FastAPI
|
|
81
|
+
from fastapi_caching_route import CachingRoute, FastAPICache
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
router = APIRouter(route_class=CachingRoute)
|
|
85
|
+
cache = FastAPICache(SimpleMemoryCache())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@cache()
|
|
89
|
+
@router.get('/')
|
|
90
|
+
def cached() -> str:
|
|
91
|
+
return 'Hello, World!'
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
app = FastAPI()
|
|
95
|
+
app.include_router(router)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Start an app with this route and call it twice. The first response is produced by
|
|
99
|
+
the endpoint and stored in the cache:
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
X-Cache: MISS
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The second response is returned from the cache before the endpoint handler is
|
|
106
|
+
called:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
X-Cache: HIT
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Routes decorated with `@cache()` must be registered on a router that uses
|
|
113
|
+
`CachingRoute`. A plain `APIRoute` ignores the cache configuration.
|
|
114
|
+
|
|
115
|
+
## Cache Keys
|
|
116
|
+
|
|
117
|
+
By default, the cache key is built from the request path and declared query
|
|
118
|
+
parameters. Query parameter order is normalized, so these two requests hit the
|
|
119
|
+
same cache entry when the endpoint declares `a` and `b`:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
/query?a=a&b=b
|
|
123
|
+
/query?b=b&a=a
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Use a custom key builder when the cache key should be based on path parameters,
|
|
127
|
+
headers, user context, or another application-specific value.
|
|
128
|
+
|
|
129
|
+
```py
|
|
130
|
+
from fastapi import Request
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def user_key_builder(request: Request) -> str:
|
|
134
|
+
user_id = request.scope['path_params']['user_id']
|
|
135
|
+
return f'user:{user_id}'
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@cache(key_builder=user_key_builder, ttl=60, namespace='users')
|
|
139
|
+
@router.get('/users/{user_id}')
|
|
140
|
+
def get_user(user_id: int) -> dict[str, int]:
|
|
141
|
+
return {'id': user_id}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`ttl` and `namespace` are passed to `aiocache`. When the underlying cache instance
|
|
145
|
+
also has a namespace, `FastAPICache` concatenates the root and endpoint namespace
|
|
146
|
+
by default:
|
|
147
|
+
|
|
148
|
+
```py
|
|
149
|
+
cache = FastAPICache(RedisCache(namespace='api'))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@cache(namespace='users')
|
|
153
|
+
@router.get('/users/{user_id}')
|
|
154
|
+
def get_user(user_id: int): ...
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Resulting namespace: "api:users"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Pass `namespace_policy="replace"` to `FastAPICache` if endpoint namespaces should
|
|
161
|
+
replace the root namespace instead.
|
|
162
|
+
|
|
163
|
+
If the response representation depends on request headers, include those headers
|
|
164
|
+
in the default cache key with `vary_headers`. The route also returns a matching
|
|
165
|
+
`Vary` header:
|
|
166
|
+
|
|
167
|
+
```py
|
|
168
|
+
@cache(vary_headers=['Accept-Language'])
|
|
169
|
+
@router.get('/localized')
|
|
170
|
+
def localized(request: Request) -> str:
|
|
171
|
+
return request.headers.get('accept-language', 'en')
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Responses with `Vary: *` are not cached.
|
|
175
|
+
|
|
176
|
+
## Dependencies Before Cache Lookup
|
|
177
|
+
|
|
178
|
+
FastAPI dependencies on the route still run on cache misses. If a dependency
|
|
179
|
+
must also be resolved before cache lookup, pass it to `@cache(dependencies=...)`.
|
|
180
|
+
This is mainly useful for security dependencies that should reject unauthorized
|
|
181
|
+
requests before a cached response can be served.
|
|
182
|
+
|
|
183
|
+
```py
|
|
184
|
+
from fastapi import Depends
|
|
185
|
+
from fastapi.security import APIKeyHeader
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
api_key = Depends(APIKeyHeader(name='X-Key'))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@cache(dependencies=[api_key])
|
|
192
|
+
@router.get('/private', dependencies=[api_key])
|
|
193
|
+
def private_data() -> str:
|
|
194
|
+
return 'secret'
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Keep the dependency on the route as well if it must be enforced for cache misses.
|
|
198
|
+
|
|
199
|
+
## Conditional Requests
|
|
200
|
+
|
|
201
|
+
Cached responses without an existing `ETag` get one based on the response body.
|
|
202
|
+
If the endpoint already sets `ETag`, that value is preserved. On a cache hit,
|
|
203
|
+
requests with a matching `If-None-Match` header return `304 Not Modified` with an
|
|
204
|
+
empty body. `If-None-Match` supports weak tags, tag lists, and `*`.
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
curl http://127.0.0.1:8000/cached -H 'If-None-Match: "..."'
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Invalidation
|
|
211
|
+
|
|
212
|
+
Use the same key builder logic when invalidating cache entries after writes.
|
|
213
|
+
|
|
214
|
+
```py
|
|
215
|
+
from fastapi import Request
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def user_cache_key(user_id: int) -> str:
|
|
219
|
+
return f'user:{user_id}'
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def user_key_builder(request: Request) -> str:
|
|
223
|
+
return user_cache_key(request.scope['path_params']['user_id'])
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@cache(key_builder=user_key_builder)
|
|
227
|
+
@router.get('/users/{user_id}')
|
|
228
|
+
def get_user(user_id: int): ...
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@router.patch('/users/{user_id}')
|
|
232
|
+
async def update_user(user_id: int) -> dict[str, int]:
|
|
233
|
+
await cache.invalidate_cached(user_cache_key(user_id))
|
|
234
|
+
return {'id': user_id}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`invalidate_cached()` returns the number of deleted keys reported by the
|
|
238
|
+
underlying cache backend.
|
|
239
|
+
|
|
240
|
+
## Examples
|
|
241
|
+
|
|
242
|
+
The repository contains runnable examples:
|
|
243
|
+
|
|
244
|
+
- `examples/simple.py`: minimal cached route.
|
|
245
|
+
- `examples/complex.py`: cache hits and misses, auth dependency, ETag handling,
|
|
246
|
+
streaming response caching, query parameter keys, and non-cacheable responses.
|
|
247
|
+
- `examples/invalidate.py`: custom key builder and manual invalidation after an
|
|
248
|
+
update.
|
|
249
|
+
|
|
250
|
+
Follow the detailed walkthrough in the examples README.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
fastapi_caching_route/__init__.py,sha256=58kkpUUo0H5rf-JOHWU5vYXqRoDu9qKGHO986Ue0aoo,221
|
|
2
|
+
fastapi_caching_route/_version.py,sha256=_JBFR15qMP_R2Doh1BNcX8m1yeXUGAmTM3CZn1PAu3g,18
|
|
3
|
+
fastapi_caching_route/_version.pyi,sha256=GxQ4ZGLPQObN92QW_Hb8IJPEuYINNn186FjrRovM09g,13
|
|
4
|
+
fastapi_caching_route/main.py,sha256=8I5mv3xFhwP_BEqFGAAeuqeCpr3oJEfKE8rjMURyEJY,18569
|
|
5
|
+
fastapi_caching_route/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
fastapi_caching_route-0.6.0.dist-info/METADATA,sha256=C4M36JNTtEV2b8Gcbi7grBXRfOZ0nH7kR_jEpqCwlFY,7988
|
|
7
|
+
fastapi_caching_route-0.6.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
fastapi_caching_route-0.6.0.dist-info/licenses/LICENSE,sha256=FlY1dZdSVly5VFy3nEK6NlAmTAQzqy8AS8oLr4hL0gI,1074
|
|
9
|
+
fastapi_caching_route-0.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Petr Tsymbarovich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|