diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index ebe7314..0618a09 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -36,7 +36,7 @@ jobs: - name: Upload Coverage Report To Code Climate uses: paambaati/codeclimate-action@v2.6.0 env: - CC_TEST_REPORTER_ID: d5526599ee5960a785d7bcea388c403e8cc9ead6f18223c6b5f7dca22c94cb46 + CC_TEST_REPORTER_ID: 30e873c5c03b343557ede56965b7e94146a21b8cbe357569f6a45365f6afaaa3 with: coverageCommand: python -m coverage xml - name: "Upload coverage to Codecov" diff --git a/README.md b/README.md index 359d5f4..d2afe8c 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,56 @@ -## `fastapi-redis-cache` +# `fastapi-redis-cache` -[![PyPI version](https://badge.fury.io/py/fastapi-redis-cache.svg)](https://badge.fury.io/py/fastapi-redis-cache) ![PyPI - Downloads](https://img.shields.io/pypi/dm/fastapi-redis-cache?color=%234DC71F) ![PyPI - License](https://img.shields.io/pypi/l/fastapi-redis-cache?color=%25234DC71F) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-redis-cache) [![Maintainability](https://api.codeclimate.com/v1/badges/4a1753c77add039c3850/maintainability)](https://codeclimate.com/github/a-luna/fastapi-redis-cache/maintainability) [![codecov](https://codecov.io/gh/a-luna/fastapi-redis-cache/branch/master/graph/badge.svg)](https://codecov.io/gh/a-luna/fastapi-redis-cache) +[![PyPI version](https://badge.fury.io/py/fastapi-redis-cache.svg)](https://badge.fury.io/py/fastapi-redis-cache) ![PyPI - Downloads](https://img.shields.io/pypi/dm/fastapi-redis-cache?color=%234DC71F) ![PyPI - License](https://img.shields.io/pypi/l/fastapi-redis-cache?color=%25234DC71F) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-redis-cache) [![Maintainability](https://api.codeclimate.com/v1/badges/ec0b1d7afb21bd8c23dc/maintainability)](https://codeclimate.com/github/a-luna/fastapi-redis-cache/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/ec0b1d7afb21bd8c23dc/test_coverage)](https://codeclimate.com/github/a-luna/fastapi-redis-cache/test_coverage) +- Cache response data for async and non-async path operation functions. + - Response data is only cached for `GET` operations, applying `@cache` decorator to path functions for other HTTP method types will have no effect. +- Lifetime of cached data is configured separately for each API endpoint. +- Requests with `Cache-Control` header containing `no-cache` or `no-store`are handled correctly (all caching behavior is disabled). +- Requests with `If-None-Match` header will receive a response with status `304 NOT MODIFIED` if `ETag` for requested resource matches header value. -### Installation +## Installation `pip install fastapi-redis-cache` -### Usage +## Usage -On startup, initialize the cache with the URL of the Redis server. The name of the custom header field used to identify cache hits/misses can also be customized. If `response_header` is not specified, the custom header field will be named `X-FastAPI-Cache` +### Initialize Redis -```python +Create a `FastApiRedisCache` instance when your application starts by [defining an event handler for the `"startup"` event](https://fastapi.tiangolo.com/advanced/events/) as shown below: + +```python {linenos=table} import os from fastapi import FastAPI, Request, Response from fastapi_redis_cache import FastApiRedisCache, cache +from sqlalchemy.orm import Session LOCAL_REDIS_URL = "redis://127.0.0.1:6379" -CACHE_HEADER = "X-MyAPI-Cache" app = FastAPI(title="FastAPI Redis Cache Example") @app.on_event("startup") def startup(): redis_cache = FastApiRedisCache() - redis_cache.connect( + redis_cache.init( host_url=os.environ.get("REDIS_URL", LOCAL_REDIS_URL), - response_header=CACHE_HEADER + prefix="myapi-cache", + response_header="X-MyAPI-Cache", + ignore_arg_types=[Request, Response, Session] ) ``` -Even if the cache has been initialized, you must apply the `@cache` decorator to each route to enable caching: +After creating the instance, you must call the `init` method. The only required argument for this method is the URL for the Redis database (`host_url`). All other arguments are optional: + +- `host_url` (`str`) — Redis database URL. (_**Required**_) +- `prefix` (`str`) — Prefix to add to every cache key stored in the Redis database. (_Optional_, defaults to `None`) +- `response_header` (`str`) — Name of the custom header field used to identify cache hits/misses. (_Optional_, defaults to `X-FastAPI-Cache`) +- `ignore_arg_types` (`List[Type[object]]`) — Cache keys are created (in part) by combining the name and value of each argument used to invoke a path operation function. If any of the arguments have no effect on the response (such as a `Request` or `Response` object), including their type in this list will ignore those arguments when the key is created. (_Optional_, defaults to `[Request, Response]`) + - The example shown here includes the `sqlalchemy.orm.Session` type, if your project uses SQLAlchemy as a dependency ([as demonstrated in the FastAPI docs](https://fastapi.tiangolo.com/tutorial/sql-databases/)), you should include `Session` in `ignore_arg_types` in order for cache keys to be created correctly ([More info](#cache-keys)). + +### `@cache` Decorator + +Decorating a path function with `@cache` enables caching for the endpoint. If no arguments are provided, responses will be set to expire after 1 year, which, historically, is the correct way to mark data that "never expires". ```python # WILL NOT be cached @@ -46,9 +65,7 @@ async def get_immutable_data(): return {"success": True, "message": "this data can be cached indefinitely"} ``` -Decorating a path function with `@cache` enables caching for the endpoint. If no arguments are provided, responses will be set to expire after 1 year, which, historically, is the correct way to mark data that "never expires". - -Response data for the API endpoint at `/immutable_data` will be cached by the Redis server. Log messages are written to standard output whenever a response is added to the cache, or a response is retrieved from the cache: +Response data for the API endpoint at `/immutable_data` will be cached by the Redis server. Log messages are written to standard output whenever a response is added to or retrieved from the cache: ```console INFO:fastapi_redis_cache:| 04/21/2021 12:26:26 AM | CONNECT_BEGIN: Attempting to connect to Redis server... @@ -59,35 +76,20 @@ INFO:fastapi_redis_cache:| 04/21/2021 12:26:45 AM | KEY_FOUND_IN_CACHE: key=api. INFO: 127.0.0.1:61779 - "GET /immutable_data HTTP/1.1" 200 OK ``` -The log messages show two successful (**`200 OK`**) responses to the same request (**`GET /immutable_data`**). The first request executed the `get_immutable_data` function and stored the result in Redis under key `api.get_immutable_data()`. The second request **did not** execute the `get_immutable_data` function, instead the cached result was retrieved and sent as the response. +The log messages show two successful (**`200 OK`**) responses to the same request (**`GET /immutable_data`**). The first request executed the `get_immutable_data` function and stored the result in Redis under key `api.get_immutable_data()`. The second request _**did not**_ execute the `get_immutable_data` function, instead the cached result was retrieved and sent as the response. -If `get_immutable_data` took a substantial time to execute, enabling caching on the endpoint would save time and CPU resources every subsequent time it is called. However, to truly take advantage of caching, you should add a `Request` and `Response` argument to the path operation function as shown below: +If data for an API endpoint needs to expire, you can specify the number of seconds before it is deleted by Redis using the `expire_after_seconds` parameter: ```python -# The expire_after_seconds argument sets the length of time until a cached -# response is deleted from Redis. @app.get("/dynamic_data") @cache(expire_after_seconds=30) def get_dynamic_data(request: Request, response: Response): return {"success": True, "message": "this data should only be cached temporarily"} ``` -If `request` and `response` are found in the path operation function, `FastApiRedisCache` can read the request header fields and modify the header fields sent with the response. To understand the difference, here is the full HTTP response for a request for `/immutable_data` (Remember, this is the first endpoint that was demonstrated and this path function **DOES NOT** contain a `Request` or `Response` object): - -```console -$ http "http://127.0.0.1:8000/immutable_data" -HTTP/1.1 200 OK -content-length: 65 -content-type: text/plain; charset=utf-8 -date: Wed, 21 Apr 2021 07:26:34 GMT -server: uvicorn -{ - "message": "this data can be cached indefinitely", - "success": true -} -``` +### Response Headers -Next, here is the HTTP response for the `/dynamic_data` endpoint. Notice the addition of the following headers: `cache-control`, `etag`, and `expires`: +Below is the HTTP response for the `/dynamic_data` endpoint. The `cache-control`, `etag`, `expires`, and `x-fastapi-cache` headers are added because of the `@cache` decorator: ```console $ http "http://127.0.0.1:8000/dynamic_data" @@ -100,12 +102,81 @@ $ http "http://127.0.0.1:8000/dynamic_data" expires: Wed, 21 Apr 2021 07:55:03 GMT server: uvicorn x-fastapi-cache: Hit + { "message": "this data should only be cached temporarily", "success": true } ``` -The header fields indicate that this response will be considered fresh for 29 seconds. This is expected since `expire_after_seconds=30` was specified in the `@cache` decorator for the `/dynamic_data` endpoint. +- The `x-fastapi-cache` header field indicates that this response was found in the Redis cache (a.k.a. a `Hit`). +- The `expires` field and `max-age` value in the `cache-control` field indicate that this response will be considered fresh for 29 seconds. This is expected since `expire_after_seconds=30` was specified in the `@cache` decorator. +- The `etag` field is an identifier that is computed by converting the response data to a string and applying a hash function. If a request containing the `if-none-match` header is received, the `etag` value will be used to determine if the requested resource has been modified. + +If this request was made from a web browser, and a request for the same resource was sent before the cached response expires, the browser would automatically serve the cached version and the request would never even be sent to the FastAPI server. + +Similarly, if a request is sent with the `cache-control` header containing `no-cache` or `no-store`, all caching behavior will be disabled and the response will be generated and sent as if endpoint had not been decorated with `@cache`. + +### Cache Keys + +Consider the `/get_item` API route defined below. This is the first path function we have seen where the response depends on the value of an argument (`user_id: int`). This is a typical CRUD operation where `user_id` is used to retrieve a `User` record from a SQLAlchemy database. + +```python +@app.get("/get_user", response_model=schemas.User) +@cache(expire_after_seconds=3600) +def get_item(user_id: int, db: Session = Depends(get_db)): + return db.query(models.User).filter(models.User.id == user_id).first() +``` + +The API route also includes a dependency that injects a Session object (`db`) into the function, [per the instructions from the FastAPI docs](https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-dependency). This is used to query the database for the `User` corresponding to the `user_id` value. + +In the [Initialize Redis](#initialize-redis) section of this document, the `FastApiRedisCache.init` method was called with `ignore_arg_types=[Request, Response, Session]`. Why is it necessary to include `Session` in this list? + +Before we can answer that question, we must understand how a cache key is created. In order to create a unique identifier for the data sent in response to an API request, the following values are combined: + +1) The optional `prefix` value provided as an argument to the `FastApiRedisCache.init` method (`"myapi-cache"`). +2) The module containing the path function (`"api"`). +3) The name of the path function (`"get_user"`). +4) The name and value of all arguments to the path function **EXCEPT for arguments with a type that exists in** `ignore_arg_types` (`"user_id=?"`). + +Therefore, all response data for the `/get_user` endpoint will have a cache key equal to `"myapi-cache:api.get_user(user_id=?)"` (e.g., for `user_id=1`, the cache key will be `"myapi-cache:api.get_user(user_id=1)"`). + +Even though `db` is an argument to the path function, it is not included in the cache key because it is a `Session` type. If `Session` had not been included in the `ignore_arg_types` list, caching would be completely broken. + +To understand why this is the case, see if you can figure out what is happening in the log messages below: + +```console +INFO:uvicorn.error:Application startup complete. +INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1,db=) +INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK +INFO:fastapi_redis_cache.client: 04/23/2021 07:04:15 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1,db=) +INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK +INFO:fastapi_redis_cache.client: 04/23/2021 07:04:17 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1,db=) +INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK +``` + +The log messages indicate that three requests were received for the same endpoint, with the same arguments (`GET /get_user?user_id=1`). However, the cache key that is created is different for each request: + +```console +KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1,db= +KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1,db= +KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1,db= +``` + +The value of each argument is added to the cache key by calling `str(arg)`. The `db` object includes the memory location when converted to a string, causing the same response data to be cached under three different keys! This is obviously not what we want. + +The correct behavior (with `Session` included in `ignore_arg_types`) is shown below: + +```console +INFO:uvicorn.error:Application startup complete. +INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(user_id=1) +INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK +INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_FOUND_IN_CACHE: key=myapi-cache:api.get_user(user_id=1) +INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK +INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_FOUND_IN_CACHE: key=myapi-cache:api.get_user(user_id=1) +INFO: 127.0.0.1:50761 - "GET /get_user?user_id=1 HTTP/1.1" 200 OK +``` + +## Questions/Contributions -This response also includes the `x-fastapi-cache` header field which tells us that this response was found in the Redis cache (a.k.a. a `Hit`). If these requests were made from a web browser, and a request for the same resource was sent before the cached response expires, the browser would automatically serve the cached version and the request would never even be sent to the FastAPI server! \ No newline at end of file +If you have any questions, please open an issue. Any suggestions and contributions are absolutely welcome. This is still a very small and young project, I plan on adding a feature roadmap and further documentation in the near future. \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a092e9b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,69 @@ +appdirs==1.4.4 +appnope==0.1.2 +asttokens==2.0.5 +async-timeout==3.0.1 +attrs==20.3.0 +backcall==0.2.0 +black==20.8b1 +certifi==2020.12.5 +chardet==4.0.0 +cheap-repr==0.4.5 +click==7.1.2 +colorclass==2.2.0 +coverage==5.5 +decorator==5.0.7 +distlib==0.3.1 +docopt==0.6.2 +executing==0.6.0 +fakeredis==1.5.0 +fastapi==0.63.0 +filelock==3.0.12 +flake8==3.9.1 +h11==0.12.0 +hiredis==2.0.0 +idna==2.10 +iniconfig==1.1.1 +ipython==7.22.0 +ipython-genutils==0.2.0 +isort==5.8.0 +jedi==0.18.0 +mccabe==0.6.1 +mypy-extensions==0.4.3 +packaging==20.9 +parso==0.8.2 +pathspec==0.8.1 +pexpect==4.8.0 +pickleshare==0.7.5 +pip-upgrader==1.4.15 +pluggy==0.13.1 +prompt-toolkit==3.0.18 +psutil==5.8.0 +ptyprocess==0.7.0 +py==1.10.0 +pycodestyle==2.7.0 +pydantic==1.8.1 +pyflakes==2.3.1 +Pygments==2.8.1 +pyparsing==2.4.7 +pytest==6.2.3 +pytest-cov==2.11.1 +pytest-flake8==1.0.7 +pytest-random-order==1.0.4 +python-dateutil==2.8.1 +redis==3.5.3 +regex==2021.4.4 +requests==2.25.1 +six==1.15.0 +snoop==0.3.0 +sortedcontainers==2.3.0 +starlette==0.13.6 +terminaltables==3.1.0 +toml==0.10.2 +tox==3.23.0 +traitlets==5.0.5 +typed-ast==1.4.3 +typing-extensions==3.7.4.3 +urllib3==1.26.4 +uvicorn==0.13.4 +virtualenv==20.4.4 +wcwidth==0.2.5 diff --git a/src/fastapi_redis_cache/cache.py b/src/fastapi_redis_cache/cache.py index fc6c4c7..9d05f13 100644 --- a/src/fastapi_redis_cache/cache.py +++ b/src/fastapi_redis_cache/cache.py @@ -1,24 +1,21 @@ """cache.py""" import asyncio -import math from datetime import timedelta from functools import wraps from http import HTTPStatus from typing import Union +from fastapi import Response + from fastapi_redis_cache.client import FastApiRedisCache -def cache( - *, - expire_after_seconds: Union[int, timedelta] = None, - expire_after_milliseconds: Union[int, timedelta] = None, -): +def cache(*, expire_after_seconds: Union[int, timedelta] = None): """Enable caching behavior for the decorated function. If no arguments are provided, this marks the response data for the decorated path function as "never expires". In this case, the `Expires` and - `Cache-Control max-age` headers will be set to expire after one year. + `Cache-Control: max-age` headers will be set to expire after one year. Historically, this was the furthest time in the future that was allowed for these fields. This is no longer the case, but it is still not advisable to use values greater than one year. @@ -26,51 +23,52 @@ def cache( Args: expire_after_seconds (Union[int, timedelta], optional): The number of seconds from now when the cached response should expire. Defaults to None. - expire_after_milliseconds (Union[int, timedelta], optional): The number of - milliseconds from now when the cached response should expire. Defaults to None. """ def outer_wrapper(func): @wraps(func) async def inner_wrapper(*args, **kwargs): - """Return cached value if one exists, otherwise the value returned by the function is added to the cache.""" + """Return cached value if one exists, otherwise evaluate the wrapped function and cache the result.""" + func_kwargs = kwargs.copy() request = func_kwargs.pop("request", None) response = func_kwargs.pop("response", None) + create_response_directly = False + if not response: + response = Response() + create_response_directly = True redis_cache = FastApiRedisCache() - if not redis_cache.connected: - # if the redis client is not connected to the server, no caching behavior is performed. - return await get_api_response_async(func, *args, **kwargs) - if not redis_cache.request_is_cacheable(request): - return await get_api_response_async(func, *args, **kwargs) + # if the redis client is not connected or request is not cacheable, no caching behavior is performed. + if redis_cache.not_connected or redis_cache.request_is_not_cacheable(request): + return await get_api_response_async(func, *args, **kwargs) key = redis_cache.get_cache_key(func, *args, **kwargs) ttl, in_cache = redis_cache.check_cache(key) if in_cache: - cached_data = redis_cache.deserialize_json(in_cache) - if response and redis_cache.requested_resource_not_modified(request, cached_data): + if redis_cache.requested_resource_not_modified(request, in_cache): response.status_code = HTTPStatus.NOT_MODIFIED return response - if response: - redis_cache.set_response_headers(response, cache_hit=True, response_data=cached_data, ttl=ttl) - return redis_cache.deserialize_json(in_cache) + cached_data = redis_cache.deserialize_json(in_cache) + redis_cache.set_response_headers(response, cache_hit=True, response_data=cached_data, ttl=ttl) + if create_response_directly: + return Response(content=in_cache, media_type="application/json", headers=response.headers) + return cached_data response_data = await get_api_response_async(func, *args, **kwargs) - redis_cache.add_to_cache(key, response_data, expire_after_seconds, expire_after_milliseconds) - if response: - ttl = calculate_ttl(expire_after_seconds, expire_after_milliseconds) - redis_cache.set_response_headers(response, cache_hit=False, response_data=response_data, ttl=ttl) + redis_cache.add_to_cache(key, response_data, expire_after_seconds) + redis_cache.set_response_headers(response, cache_hit=False, response_data=response_data, ttl=ttl) + if create_response_directly: + return Response( + content=redis_cache.serialize_json(response_data), + media_type="application/json", + headers=response.headers, + ) return response_data - def calculate_ttl(expire_s=0, expire_ms=0): - if not expire_s and not expire_ms: - return -1 - ttl = expire_s + math.floor(expire_ms / 1000) - return ttl or 1 - - async def get_api_response_async(func, *args, **kwargs): - """Helper function that allows decorator to work with both async and non-async functions.""" - return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs) - return inner_wrapper return outer_wrapper + + +async def get_api_response_async(func, *args, **kwargs): + """Helper function that allows decorator to work with both async and non-async functions.""" + return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs) diff --git a/src/fastapi_redis_cache/client.py b/src/fastapi_redis_cache/client.py index 49defeb..f400602 100644 --- a/src/fastapi_redis_cache/client.py +++ b/src/fastapi_redis_cache/client.py @@ -12,7 +12,7 @@ from fastapi_redis_cache.util import deserialize_json, serialize_json DEFAULT_RESPONSE_HEADER = "X-FastAPI-Cache" -DEFAULT_ALLOWED_TYPES = ["GET"] +ALLOWED_HTTP_TYPES = ["GET"] ONE_YEAR_IN_SECONDS = 31535990 LOG_TIMESTAMP = "%m/%d/%Y %I:%M:%S %p" HTTP_TIME = "%a, %d %b %Y %H:%M:%S GMT" @@ -47,18 +47,35 @@ class FastApiRedisCache(metaclass=MetaSingleton): def connected(self): return self.status == RedisStatus.CONNECTED - def connect( + @property + def not_connected(self): + return not self.connected + + def init( self, - host_url: RedisDsn, + host_url: str, prefix: Optional[str] = None, response_header: Optional[str] = None, allow_request_types: List[str] = None, ignore_arg_types: Optional[List[Type[object]]] = None, ) -> None: + """Connect to a Redis database using `host_url` and configure cache settings. + + Args: + host_url (str): URL for a Redis database. + prefix (str, optional): Prefix to add to every cache key stored in the + Redis database. Defaults to None. + response_header (str, optional): Name of the custom header field used to + identify cache hits/misses. Defaults to None. + ignore_arg_types (List[Type[object]], optional): Each argument to the + API endpoint function is used to compose the cache key. If there + are any arguments that have no effect on the response (such as a + `Request` or `Response` object), including their type in this list + will ignore those arguments when the key is created. Defaults to None. + """ self.host_url = host_url self.prefix = prefix self.response_header = response_header or DEFAULT_RESPONSE_HEADER - self.allow_request_types = allow_request_types or DEFAULT_ALLOWED_TYPES self.ignore_arg_types = ignore_arg_types self._connect() @@ -67,19 +84,19 @@ def _connect(self): self.status, self.redis = redis_connect(self.host_url) if self.status == RedisStatus.CONNECTED: self.log(RedisEvent.CONNECT_SUCCESS, msg="Redis client is connected to server.") - if self.status == RedisStatus.AUTH_ERROR: + if self.status == RedisStatus.AUTH_ERROR: # pragma: no cover self.log(RedisEvent.CONNECT_FAIL, msg="Unable to connect to redis server due to authentication error.") - if self.status == RedisStatus.CONN_ERROR: + if self.status == RedisStatus.CONN_ERROR: # pragma: no cover self.log(RedisEvent.CONNECT_FAIL, msg="Redis server did not respond to PING message.") - def request_is_cacheable(self, request: Request) -> bool: - return not request or ( - request.method in self.allow_request_types - and all(directive not in request.headers.get("Cache-Control", "") for directive in ["no-store", "no-cache"]) + def request_is_not_cacheable(self, request: Request) -> bool: + return request and ( + request.method not in ALLOWED_HTTP_TYPES + or any(directive in request.headers.get("Cache-Control", "") for directive in ["no-store", "no-cache"]) ) def get_cache_key(self, func: Callable, *args: List, **kwargs: Dict) -> str: - return get_cache_key(func, self.ignore_arg_types, *args, **kwargs) + return get_cache_key(func, self.prefix, self.ignore_arg_types, *args, **kwargs) def check_cache(self, key: str) -> Tuple[int, str]: pipe = self.redis.pipeline() @@ -88,7 +105,7 @@ def check_cache(self, key: str) -> Tuple[int, str]: self.log(RedisEvent.KEY_FOUND_IN_CACHE, key=key) return (ttl, in_cache) - def requested_resource_not_modified(self, request: Request, cached_data: Union[str, Dict]) -> bool: + def requested_resource_not_modified(self, request: Request, cached_data: str) -> bool: if not request or "If-None-Match" not in request.headers: return False check_etags = [etag.strip() for etag in request.headers["If-None-Match"].split(",") if etag] @@ -96,28 +113,22 @@ def requested_resource_not_modified(self, request: Request, cached_data: Union[s return True return self.get_etag(cached_data) in check_etags - def add_to_cache( - self, - key: str, - value: str, - ex: Optional[Union[int, timedelta]] = None, - px: Optional[Union[int, timedelta]] = None, - ) -> None: - if self.redis.set(name=key, value=self.serialize_json(value), ex=ex, px=px): + def add_to_cache(self, key: str, value: Dict, expire_after_seconds: Optional[Union[int, timedelta]] = None) -> None: + expire_after_seconds = expire_after_seconds or ONE_YEAR_IN_SECONDS + if self.redis.set(name=key, value=self.serialize_json(value), ex=expire_after_seconds): self.log(RedisEvent.KEY_ADDED_TO_CACHE, key=key) - else: + else: # pragma: no cover self.log(RedisEvent.FAILED_TO_CACHE_KEY, key=key, value=value) def set_response_headers( - self, response: Response, cache_hit: bool, response_data: str = None, ttl: int = None + self, response: Response, cache_hit: bool, response_data: Dict = None, ttl: int = None ) -> None: response.headers[self.response_header] = "Hit" if cache_hit else "Miss" - ttl = ttl if ttl != -1 else ONE_YEAR_IN_SECONDS expires_at = datetime.utcnow() + timedelta(seconds=ttl) response.headers["Expires"] = expires_at.strftime(HTTP_TIME) response.headers["Cache-Control"] = f"max-age={ttl}" response.headers["ETag"] = self.get_etag(response_data) - if "last_modified" in response_data: + if "last_modified" in response_data: # pragma: no cover response.headers["Last-Modified"] = response_data["last_modified"] def log(self, event: RedisEvent, msg: Optional[str] = None, key: Optional[str] = None, value: Optional[str] = None): @@ -127,7 +138,7 @@ def log(self, event: RedisEvent, msg: Optional[str] = None, key: Optional[str] = message += f": {msg}" if key: message += f": key={key}" - if value: + if value: # pragma: no cover message += f", value={value}" logger.info(message) @@ -141,8 +152,10 @@ def serialize_json(json_dict: Dict) -> str: @staticmethod def get_etag(cached_data: Union[str, Dict]) -> str: - if not isinstance(cached_data, str): - cached_data = str(cached_data) + if isinstance(cached_data, dict): + cached_data = serialize_json(cached_data) + if isinstance(cached_data, bytes): + cached_data = cached_data.decode() return f"W/{hash(cached_data)}" @staticmethod diff --git a/src/fastapi_redis_cache/enums.py b/src/fastapi_redis_cache/enums.py index 0772f66..8c24b52 100644 --- a/src/fastapi_redis_cache/enums.py +++ b/src/fastapi_redis_cache/enums.py @@ -9,9 +9,6 @@ class RedisStatus(IntEnum): AUTH_ERROR = 2 CONN_ERROR = 3 - def __str__(self): - return self.name - class RedisEvent(IntEnum): """Redis client events.""" @@ -22,6 +19,3 @@ class RedisEvent(IntEnum): KEY_ADDED_TO_CACHE = 4 KEY_FOUND_IN_CACHE = 5 FAILED_TO_CACHE_KEY = 6 - - def __str__(self): - return self.name diff --git a/src/fastapi_redis_cache/key_gen.py b/src/fastapi_redis_cache/key_gen.py index adbca0b..ef0e7b4 100644 --- a/src/fastapi_redis_cache/key_gen.py +++ b/src/fastapi_redis_cache/key_gen.py @@ -10,16 +10,34 @@ ALWAYS_IGNORE_ARG_TYPES = [Response, Request] -def get_cache_key(func: Callable, ignore_arg_types: List[ArgType], *args: List, **kwargs: Dict) -> str: - """Ganerate a string that uniquely identifies the function and values of all arguments.""" +def get_cache_key(func: Callable, prefix: str, ignore_arg_types: List[ArgType], *args: List, **kwargs: Dict) -> str: + """Ganerate a string that uniquely identifies the function and values of all arguments. + + Args: + func (`Callable`): Path operation function for an API endpoint. + prefix (`str`): Customizable namespace value that will prefix all cache keys. + ignore_arg_types (`List[ArgType]`): Each argument to the API endpoint function is + used to compose the cache key by calling `str(arg)`. If there are any keys that + should not be used in this way (i.e., because their value has no effect on the + response, such as a `Request` or `Response` object) you can remove them from + the cache key by including their type as a list item in ignore_key_types. + + Returns: + `str`: Unique identifier for `func`, `*args` and `**kwargs` that can be used as a + Redis key to retrieve cached API response data. + """ + if not ignore_arg_types: ignore_arg_types = [] ignore_arg_types.extend(ALWAYS_IGNORE_ARG_TYPES) + ignore_arg_types = list(set(ignore_arg_types)) + prefix = f"{prefix}:" if prefix else "" + sig = signature(func) sig_params = sig.parameters func_args = get_func_args(sig, *args, **kwargs) args_str = get_args_str(sig_params, func_args, ignore_arg_types) - return f"{func.__module__}.{func.__name__}({args_str})" + return f"{prefix}{func.__module__}.{func.__name__}({args_str})" def get_func_args(sig: Signature, *args: List, **kwargs: Dict) -> "OrderedDict[str, Any]": @@ -31,7 +49,7 @@ def get_func_args(sig: Signature, *args: List, **kwargs: Dict) -> "OrderedDict[s def get_args_str(sig_params: SigParameters, func_args: "OrderedDict[str, Any]", ignore_arg_types: List[ArgType]) -> str: """Return a string with the name and value of all args whose type is not included in `ignore_arg_types`""" - return "_".join( + return ",".join( f"{arg}={val}" for arg, val in func_args.items() if not ignore_arg_type(arg, sig_params, ignore_arg_types) ) diff --git a/src/fastapi_redis_cache/redis.py b/src/fastapi_redis_cache/redis.py index f50a9c8..06cd3a3 100644 --- a/src/fastapi_redis_cache/redis.py +++ b/src/fastapi_redis_cache/redis.py @@ -3,17 +3,16 @@ from typing import Tuple import redis -from pydantic import RedisDsn from fastapi_redis_cache.enums import RedisStatus -def redis_connect(host_url: RedisDsn) -> Tuple[RedisStatus, redis.client.Redis]: +def redis_connect(host_url: str) -> Tuple[RedisStatus, redis.client.Redis]: """Attempt to connect to `host_url` and return a Redis client instance if successful.""" return _connect(host_url) if os.environ.get("CACHE_ENV") != "TEST" else _connect_fake() -def _connect(host_url: RedisDsn) -> Tuple[RedisStatus, redis.client.Redis]: +def _connect(host_url: str) -> Tuple[RedisStatus, redis.client.Redis]: # pragma: no cover try: redis_client = redis.from_url(host_url) if redis_client.ping(): diff --git a/src/fastapi_redis_cache/util.py b/src/fastapi_redis_cache/util.py index 4f8ac04..50364e1 100644 --- a/src/fastapi_redis_cache/util.py +++ b/src/fastapi_redis_cache/util.py @@ -22,7 +22,7 @@ def default(self, obj): return {"val": obj.strftime(DATE_ONLY), "_spec_type": str(date)} elif isinstance(obj, Decimal): return {"val": str(obj), "_spec_type": str(Decimal)} - else: + else: # pragma: no cover return super().default(obj) @@ -30,7 +30,7 @@ def object_hook(obj): if "_spec_type" not in obj: return obj _spec_type = obj["_spec_type"] - if _spec_type not in SERIALIZE_OBJ_MAP: + if _spec_type not in SERIALIZE_OBJ_MAP: # pragma: no cover raise TypeError(f'"{obj["val"]}" (type: {_spec_type}) is not JSON serializable') return SERIALIZE_OBJ_MAP[_spec_type](obj["val"]) diff --git a/src/fastapi_redis_cache/version.py b/src/fastapi_redis_cache/version.py index 80018fb..4240447 100644 --- a/src/fastapi_redis_cache/version.py +++ b/src/fastapi_redis_cache/version.py @@ -1,3 +1,3 @@ # flake8: noqa -__version_info__ = ("0", "1", "1") -__version__ = ".".join(__version_info__) +__version_info__ = ("0", "1", "2") # pragma: no cover +__version__ = ".".join(__version_info__) # pragma: no cover diff --git a/tests/conftest.py b/tests/conftest.py index 0bed3a9..af52af5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,5 +10,5 @@ def test_setup(request): """Setup TEST environment to use FakeRedis.""" os.environ["CACHE_ENV"] = "TEST" redis_cache = FastApiRedisCache() - redis_cache.connect(host_url="") + redis_cache.init(host_url="") return True diff --git a/tests/main.py b/tests/main.py index 3c34aea..ab3bf8e 100644 --- a/tests/main.py +++ b/tests/main.py @@ -1,3 +1,6 @@ +from datetime import date, datetime +from decimal import Decimal + from fastapi import FastAPI, Request, Response from fastapi_redis_cache import cache @@ -9,3 +12,20 @@ @cache() def cache_never_expire(request: Request, response: Response): return {"success": True, "message": "this data can be cached indefinitely"} + + +@app.get("/cache_expires") +@cache(expire_after_seconds=8) +async def cache_expires(): + return {"success": True, "message": "this data should be cached for eight seconds"} + + +@app.get("/cache_json_encoder") +@cache() +def cache_json_encoder(): + return { + "success": True, + "start_time": datetime(2021, 4, 20, 7, 17, 17), + "finish_by": date(2021, 4, 21), + "final_calc": Decimal(3.14), + } diff --git a/tests/test_cache.py b/tests/test_cache.py index b99e1f4..d4925e6 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,5 +1,11 @@ +import json +import time +from datetime import datetime +from decimal import Decimal + from fastapi.testclient import TestClient +from fastapi_redis_cache.util import deserialize_json from tests.main import app client = TestClient(app) @@ -14,6 +20,102 @@ def test_cache_never_expire(): assert "expires" in response.headers assert "etag" in response.headers response = client.get("/cache_never_expire") + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} + assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Hit" + assert "cache-control" in response.headers + assert "expires" in response.headers + assert "etag" in response.headers + + +def test_cache_expires(): + start = datetime.now() + response = client.get("/cache_expires") + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "this data should be cached for eight seconds"} + assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss" + assert "cache-control" in response.headers + assert "expires" in response.headers + assert "etag" in response.headers + check_etag = response.headers["etag"] + response = client.get("/cache_expires") + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "this data should be cached for eight seconds"} + assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Hit" + assert "cache-control" in response.headers + assert "expires" in response.headers + assert "etag" in response.headers + assert response.headers["etag"] == check_etag + elapsed = (datetime.now() - start).total_seconds() + remaining = 8 - elapsed + if remaining > 0: + time.sleep(remaining) + response = client.get("/cache_expires") + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "this data should be cached for eight seconds"} + assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss" + assert "cache-control" in response.headers + assert "expires" in response.headers + assert "etag" in response.headers + assert response.headers["etag"] == check_etag + + +def test_cache_json_encoder(): + response = client.get("/cache_json_encoder") + assert response.status_code == 200 + response_json = response.json() + assert response_json == { + "success": True, + "start_time": {"_spec_type": "", "val": "04/20/2021 07:17:17 AM "}, + "finish_by": {"_spec_type": "", "val": "04/21/2021"}, + "final_calc": { + "_spec_type": "", + "val": "3.140000000000000124344978758017532527446746826171875", + }, + } + json_dict = deserialize_json(json.dumps(response_json)) + assert json_dict["start_time"] == datetime(2021, 4, 20, 7, 17, 17) + assert json_dict["finish_by"] == datetime(2021, 4, 21) + assert json_dict["final_calc"] == Decimal(3.14) + + +def test_cache_control_no_cache(): + response = client.get("/cache_never_expire", headers={"cache-control": "no-cache"}) + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} + assert "x-fastapi-cache" not in response.headers + assert "cache-control" not in response.headers + assert "expires" not in response.headers + assert "etag" not in response.headers + + +def test_cache_control_no_store(): + response = client.get("/cache_never_expire", headers={"cache-control": "no-store"}) + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} + assert "x-fastapi-cache" not in response.headers + assert "cache-control" not in response.headers + assert "expires" not in response.headers + assert "etag" not in response.headers + + +def test_if_none_match(): + response = client.get("/cache_never_expire") + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} + assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss" + assert "cache-control" in response.headers + assert "expires" in response.headers + assert "etag" in response.headers + etag = response.headers["etag"] + invalid_etag = "W/-5480454928453453778" + response = client.get("/cache_never_expire", headers={"if-none-match": f"{etag}, {invalid_etag}"}) + assert response.status_code == 304 + response = client.get("/cache_never_expire", headers={"if-none-match": "*"}) + assert response.status_code == 304 + response = client.get("/cache_never_expire", headers={"if-none-match": invalid_etag}) + assert response.status_code == 200 + assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Hit" assert "cache-control" in response.headers assert "expires" in response.headers