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.
@@ -0,0 +1,6 @@
1
+ """FastAPI Caching Route."""
2
+
3
+ __all__ = ['CachingRoute', 'FastAPICache', '__version__']
4
+
5
+ from fastapi_caching_route._version import version as __version__
6
+ from fastapi_caching_route.main import CachingRoute, FastAPICache
@@ -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
+ [![PR Checks](https://github.com/AgroDT/fastapi-caching-route/actions/workflows/pr.yaml/badge.svg)](https://github.com/AgroDT/fastapi-caching-route/actions/workflows/pr.yaml)
24
+ [![Coverage](https://github.com/AgroDT/fastapi-caching-route/actions/workflows/coverage.yaml/badge.svg)](https://github.com/AgroDT/fastapi-caching-route/actions/workflows/coverage.yaml)
25
+ [![Coverage Status](https://coveralls.io/repos/github/AgroDT/fastapi-caching-route/badge.svg)](https://coveralls.io/github/AgroDT/fastapi-caching-route)
26
+ [![Docs](https://github.com/AgroDT/fastapi-caching-route/actions/workflows/deploy-docs.yaml/badge.svg)](https://github.com/AgroDT/fastapi-caching-route/actions/workflows/deploy-docs.yaml)
27
+ [![PyPI](https://img.shields.io/pypi/v/fastapi-caching-route.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.