mirror of
https://github.com/long2ice/fastapi-cache.git
synced 2026-03-25 04:57:54 +00:00
Instead of assuming that the Request and Response injected keyword arguments can be named `request` and `response`, use namespaced keyword names starting with a double underscore. By default the parameter names now start with `__fastapi_cache_` and so are a) clearly marked as internal, and b) highly unlikely to clash with existing keyword arguments. The prefix is configurable in the unlikely event that the names would clash in specific cases.
190 lines
7.0 KiB
Python
190 lines
7.0 KiB
Python
import logging
|
|
import sys
|
|
from functools import wraps
|
|
from inspect import Parameter, Signature, isawaitable, iscoroutinefunction
|
|
from typing import Awaitable, Callable, Optional, Type, TypeVar
|
|
|
|
if sys.version_info >= (3, 10):
|
|
from typing import ParamSpec
|
|
else:
|
|
from typing_extensions import ParamSpec
|
|
|
|
from fastapi.concurrency import run_in_threadpool
|
|
from fastapi.dependencies.utils import get_typed_return_annotation, get_typed_signature
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
from fastapi_cache import FastAPICache
|
|
from fastapi_cache.coder import Coder
|
|
from fastapi_cache.types import KeyBuilder
|
|
|
|
logger: logging.Logger = logging.getLogger(__name__)
|
|
logger.addHandler(logging.NullHandler())
|
|
P = ParamSpec("P")
|
|
R = TypeVar("R")
|
|
|
|
|
|
def _augment_signature(signature: Signature, *extra: Parameter) -> Signature:
|
|
if not extra:
|
|
return signature
|
|
|
|
parameters = list(signature.parameters.values())
|
|
variadic_keyword_params = []
|
|
while parameters and parameters[-1].kind is Parameter.VAR_KEYWORD:
|
|
variadic_keyword_params.append(parameters.pop())
|
|
|
|
return signature.replace(parameters=[*parameters, *extra, *variadic_keyword_params])
|
|
|
|
|
|
def _locate_param(sig: Signature, dep: Parameter, to_inject: list[Parameter]) -> Parameter:
|
|
"""Locate an existing parameter in the decorated endpoint
|
|
|
|
If not found, returns the injectable parameter, and adds it to the to_inject list.
|
|
|
|
"""
|
|
param = next(
|
|
(param for param in sig.parameters.values() if param.annotation is dep.annotation),
|
|
None,
|
|
)
|
|
if param is None:
|
|
to_inject.append(dep)
|
|
param = dep
|
|
return param
|
|
|
|
|
|
def cache(
|
|
expire: Optional[int] = None,
|
|
coder: Optional[Type[Coder]] = None,
|
|
key_builder: Optional[KeyBuilder] = None,
|
|
namespace: Optional[str] = "",
|
|
injected_dependency_namespace: str = "__fastapi_cache",
|
|
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
|
|
"""
|
|
cache all function
|
|
:param namespace:
|
|
:param expire:
|
|
:param coder:
|
|
:param key_builder:
|
|
|
|
:return:
|
|
"""
|
|
|
|
injected_request = Parameter(
|
|
name=f"{injected_dependency_namespace}_request",
|
|
annotation=Request,
|
|
kind=Parameter.KEYWORD_ONLY,
|
|
)
|
|
injected_response = Parameter(
|
|
name=f"{injected_dependency_namespace}_response",
|
|
annotation=Response,
|
|
kind=Parameter.KEYWORD_ONLY,
|
|
)
|
|
|
|
def wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
|
# get_typed_signature ensures that any forward references are resolved first
|
|
wrapped_signature = get_typed_signature(func)
|
|
to_inject: list[Parameter] = []
|
|
request_param = _locate_param(wrapped_signature, injected_request, to_inject)
|
|
response_param = _locate_param(wrapped_signature, injected_response, to_inject)
|
|
return_type = get_typed_return_annotation(func)
|
|
|
|
@wraps(func)
|
|
async def inner(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
nonlocal coder
|
|
nonlocal expire
|
|
nonlocal key_builder
|
|
|
|
async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
"""Run cached sync functions in thread pool just like FastAPI."""
|
|
# if the wrapped function does NOT have request or response in
|
|
# its function signature, make sure we don't pass them in as
|
|
# keyword arguments
|
|
kwargs.pop(injected_request.name, None)
|
|
kwargs.pop(injected_response.name, None)
|
|
|
|
if iscoroutinefunction(func):
|
|
# async, return as is.
|
|
# unintuitively, we have to await once here, so that caller
|
|
# does not have to await twice. See
|
|
# https://stackoverflow.com/a/59268198/532513
|
|
return await func(*args, **kwargs)
|
|
else:
|
|
# sync, wrap in thread and return async
|
|
# see above why we have to await even although caller also awaits.
|
|
return await run_in_threadpool(func, *args, **kwargs)
|
|
|
|
copy_kwargs = kwargs.copy()
|
|
request: Optional[Request] = copy_kwargs.pop(request_param.name, None)
|
|
response: Optional[Response] = copy_kwargs.pop(response_param.name, None)
|
|
if (
|
|
request and request.headers.get("Cache-Control") in ("no-store", "no-cache")
|
|
) or not FastAPICache.get_enable():
|
|
return await ensure_async_func(*args, **kwargs)
|
|
|
|
coder = coder or FastAPICache.get_coder()
|
|
expire = expire or FastAPICache.get_expire()
|
|
key_builder = key_builder or FastAPICache.get_key_builder()
|
|
backend = FastAPICache.get_backend()
|
|
|
|
cache_key = key_builder(
|
|
func,
|
|
namespace,
|
|
request=request,
|
|
response=response,
|
|
args=args,
|
|
kwargs=copy_kwargs,
|
|
)
|
|
if isawaitable(cache_key):
|
|
cache_key = await cache_key
|
|
|
|
try:
|
|
ttl, ret = await backend.get_with_ttl(cache_key)
|
|
except Exception:
|
|
logger.warning(
|
|
f"Error retrieving cache key '{cache_key}' from backend:", exc_info=True
|
|
)
|
|
ttl, ret = 0, None
|
|
if not request:
|
|
if ret is not None:
|
|
return coder.decode_as_type(ret, type_=return_type)
|
|
ret = await ensure_async_func(*args, **kwargs)
|
|
try:
|
|
await backend.set(cache_key, coder.encode(ret), expire)
|
|
except Exception:
|
|
logger.warning(
|
|
f"Error setting cache key '{cache_key}' in backend:", exc_info=True
|
|
)
|
|
return ret
|
|
|
|
if request.method != "GET":
|
|
return await ensure_async_func(*args, **kwargs)
|
|
|
|
if_none_match = request.headers.get("if-none-match")
|
|
if ret is not None:
|
|
if response:
|
|
response.headers["Cache-Control"] = f"max-age={ttl}"
|
|
etag = f"W/{hash(ret)}"
|
|
if if_none_match == etag:
|
|
response.status_code = 304
|
|
return response
|
|
response.headers["ETag"] = etag
|
|
return coder.decode_as_type(ret, type_=return_type)
|
|
|
|
ret = await ensure_async_func(*args, **kwargs)
|
|
encoded_ret = coder.encode(ret)
|
|
|
|
try:
|
|
await backend.set(cache_key, encoded_ret, expire)
|
|
except Exception:
|
|
logger.warning(f"Error setting cache key '{cache_key}' in backend:", exc_info=True)
|
|
|
|
response.headers["Cache-Control"] = f"max-age={expire}"
|
|
etag = f"W/{hash(encoded_ret)}"
|
|
response.headers["ETag"] = etag
|
|
return ret
|
|
|
|
inner.__signature__ = _augment_signature(wrapped_signature, *to_inject)
|
|
return inner
|
|
|
|
return wrapper
|