Skip to content

Commit 233835b

Browse files
authored
Merge pull request a-luna#50 from a-luna/patch-release/v0.2.5
v0.2.5
2 parents 0235bda + 946f8ae commit 233835b

File tree

7 files changed

+140
-33
lines changed

7 files changed

+140
-33
lines changed

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
## fastapi-redis-cache
1+
## fastapi-redis-cache <!-- omit in toc -->
22

33
[![PyPI version](https://badge.fury.io/py/fastapi-redis-cache.svg)](https://badge.fury.io/py/fastapi-redis-cache)
44
![PyPI - Downloads](https://img.shields.io/pypi/dm/fastapi-redis-cache?color=%234DC71F)
@@ -7,6 +7,17 @@
77
[![Maintainability](https://api.codeclimate.com/v1/badges/ec0b1d7afb21bd8c23dc/maintainability)](https://codeclimate.com/github/a-luna/fastapi-redis-cache/maintainability)
88
[![codecov](https://codecov.io/gh/a-luna/fastapi-redis-cache/branch/main/graph/badge.svg?token=dUaILJcgWY)](https://codecov.io/gh/a-luna/fastapi-redis-cache)
99

10+
- [Features](#features)
11+
- [Installation](#installation)
12+
- [Usage](#usage)
13+
- [Initialize Redis](#initialize-redis)
14+
- [`@cache` Decorator](#cache-decorator)
15+
- [Response Headers](#response-headers)
16+
- [Pre-defined Lifetimes](#pre-defined-lifetimes)
17+
- [Cache Keys](#cache-keys)
18+
- [Cache Keys Pt 2.](#cache-keys-pt-2)
19+
- [Questions/Contributions](#questionscontributions)
20+
1021
## Features
1122

1223
- Cache response data for async and non-async path operation functions.

requirements-dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ black==20.8b1
22
coverage==5.5
33
fakeredis==1.5.2
44
flake8==3.9.2
5-
isort==5.8.0
5+
isort==5.9.1
66
pytest==6.2.4
77
pytest-cov==2.12.1
88
pytest-flake8==1.0.7

src/fastapi_redis_cache/cache.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,24 +46,35 @@ async def inner_wrapper(*args, **kwargs):
4646
key = redis_cache.get_cache_key(func, *args, **kwargs)
4747
ttl, in_cache = redis_cache.check_cache(key)
4848
if in_cache:
49+
redis_cache.set_response_headers(response, True, deserialize_json(in_cache), ttl)
4950
if redis_cache.requested_resource_not_modified(request, in_cache):
5051
response.status_code = int(HTTPStatus.NOT_MODIFIED)
51-
return response
52-
cached_data = deserialize_json(in_cache)
53-
redis_cache.set_response_headers(response, cache_hit=True, response_data=cached_data, ttl=ttl)
54-
if create_response_directly:
55-
return Response(content=in_cache, media_type="application/json", headers=response.headers)
56-
return cached_data
52+
return (
53+
Response(
54+
content=None,
55+
status_code=response.status_code,
56+
media_type="application/json",
57+
headers=response.headers,
58+
)
59+
if create_response_directly
60+
else response
61+
)
62+
return (
63+
Response(content=in_cache, media_type="application/json", headers=response.headers)
64+
if create_response_directly
65+
else deserialize_json(in_cache)
66+
)
5767
response_data = await get_api_response_async(func, *args, **kwargs)
5868
ttl = calculate_ttl(expire)
5969
cached = redis_cache.add_to_cache(key, response_data, ttl)
6070
if cached:
6171
redis_cache.set_response_headers(response, cache_hit=False, response_data=response_data, ttl=ttl)
62-
if create_response_directly:
63-
return Response(
64-
content=serialize_json(response_data),
65-
media_type="application/json",
66-
headers=response.headers,
72+
return (
73+
Response(
74+
content=serialize_json(response_data), media_type="application/json", headers=response.headers
75+
)
76+
if create_response_directly
77+
else response_data
6778
)
6879
return response_data
6980

@@ -78,6 +89,7 @@ async def get_api_response_async(func, *args, **kwargs):
7889

7990

8091
def calculate_ttl(expire: Union[int, timedelta]) -> int:
92+
""""Converts expire time to total seconds and ensures that ttl is capped at one year."""
8193
if isinstance(expire, timedelta):
8294
expire = int(expire.total_seconds())
8395
return min(expire, ONE_YEAR_IN_SECONDS)

src/fastapi_redis_cache/client.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,3 @@ def get_etag(cached_data: Union[str, bytes, Dict]) -> str:
158158
def get_log_time():
159159
"""Get a timestamp to include with a log message."""
160160
return datetime.now().strftime(LOG_TIMESTAMP)
161-
162-
@staticmethod
163-
def hasmethod(obj, method_name):
164-
"""Return True if obj.method_name exists and is callable. Otherwise, return False."""
165-
obj_method = getattr(obj, method_name, None)
166-
return callable(obj_method) if obj_method else False

src/fastapi_redis_cache/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# flake8: noqa
2-
__version_info__ = ("0", "2", "4") # pragma: no cover
2+
__version_info__ = ("0", "2", "5") # pragma: no cover
33
__version__ = ".".join(__version_info__) # pragma: no cover

tests/main.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import logging
12
from datetime import date, datetime, timedelta
23
from decimal import Decimal
34

45
from fastapi import FastAPI, Request, Response
56

6-
from fastapi_redis_cache import cache, cache_one_hour
7+
from fastapi_redis_cache import cache, cache_one_hour, cache_one_minute
78

89
app = FastAPI(title="FastAPI Redis Cache Test App")
910

@@ -15,9 +16,9 @@ def cache_never_expire(request: Request, response: Response):
1516

1617

1718
@app.get("/cache_expires")
18-
@cache(expire=timedelta(seconds=8))
19+
@cache(expire=timedelta(seconds=5))
1920
async def cache_expires():
20-
return {"success": True, "message": "this data should be cached for eight seconds"}
21+
return {"success": True, "message": "this data should be cached for five seconds"}
2122

2223

2324
@app.get("/cache_json_encoder")
@@ -35,3 +36,12 @@ def cache_json_encoder():
3536
@cache_one_hour()
3637
def partial_cache_one_hour(response: Response):
3738
return {"success": True, "message": "this data should be cached for one hour"}
39+
40+
41+
@app.get("/cache_invalid_type")
42+
@cache_one_minute()
43+
def cache_invalid_type(request: Request, response: Response):
44+
logging.basicConfig()
45+
logger = logging.getLogger(__name__)
46+
logger.setLevel(logging.INFO)
47+
return logger

tests/test_cache.py

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from datetime import datetime
55
from decimal import Decimal
66

7+
import pytest
78
from fastapi.testclient import TestClient
9+
from fastapi_redis_cache.client import HTTP_TIME
810

911
from fastapi_redis_cache.util import deserialize_json
1012
from tests.main import app
@@ -14,13 +16,16 @@
1416

1517

1618
def test_cache_never_expire():
19+
# Initial request, X-FastAPI-Cache header field should equal "Miss"
1720
response = client.get("/cache_never_expire")
1821
assert response.status_code == 200
1922
assert response.json() == {"success": True, "message": "this data can be cached indefinitely"}
2023
assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss"
2124
assert "cache-control" in response.headers
2225
assert "expires" in response.headers
2326
assert "etag" in response.headers
27+
28+
# Send request to same endpoint, X-FastAPI-Cache header field should now equal "Hit"
2429
response = client.get("/cache_never_expire")
2530
assert response.status_code == 200
2631
assert response.json() == {"success": True, "message": "this data can be cached indefinitely"}
@@ -31,38 +36,72 @@ def test_cache_never_expire():
3136

3237

3338
def test_cache_expires():
34-
start = datetime.now()
39+
# Store time when response data was added to cache
40+
added_at_utc = datetime.utcnow()
41+
42+
# Initial request, X-FastAPI-Cache header field should equal "Miss"
3543
response = client.get("/cache_expires")
3644
assert response.status_code == 200
37-
assert response.json() == {"success": True, "message": "this data should be cached for eight seconds"}
45+
assert response.json() == {"success": True, "message": "this data should be cached for five seconds"}
3846
assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss"
3947
assert "cache-control" in response.headers
4048
assert "expires" in response.headers
4149
assert "etag" in response.headers
50+
51+
# Store eTag value from response header
4252
check_etag = response.headers["etag"]
53+
54+
# Send request, X-FastAPI-Cache header field should now equal "Hit"
4355
response = client.get("/cache_expires")
4456
assert response.status_code == 200
45-
assert response.json() == {"success": True, "message": "this data should be cached for eight seconds"}
57+
assert response.json() == {"success": True, "message": "this data should be cached for five seconds"}
4658
assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Hit"
47-
assert "cache-control" in response.headers
48-
assert "expires" in response.headers
59+
60+
# Verify eTag value matches the value stored from the initial response
4961
assert "etag" in response.headers
5062
assert response.headers["etag"] == check_etag
51-
elapsed = (datetime.now() - start).total_seconds()
52-
remaining = 8 - elapsed
53-
if remaining > 0:
54-
time.sleep(remaining)
63+
64+
# Store 'max-age' value of 'cache-control' header field
65+
assert "cache-control" in response.headers
66+
match = MAX_AGE_REGEX.search(response.headers.get("cache-control"))
67+
assert match
68+
ttl = int(match.groupdict()["ttl"])
69+
assert ttl <= 5
70+
71+
# Store value of 'expires' header field
72+
assert "expires" in response.headers
73+
expire_at_utc = datetime.strptime(response.headers["expires"], HTTP_TIME)
74+
75+
# Wait until expire time has passed
76+
now = datetime.utcnow()
77+
while expire_at_utc > now:
78+
time.sleep(1)
79+
now = datetime.utcnow()
80+
81+
# Wait one additional second to ensure redis has deleted the expired response data
82+
time.sleep(1)
83+
second_request_utc = datetime.utcnow()
84+
85+
# Verify that the time elapsed since the data was added to the cache is greater than the ttl value
86+
elapsed = (second_request_utc - added_at_utc).total_seconds()
87+
assert elapsed > ttl
88+
89+
# Send request, X-FastAPI-Cache header field should equal "Miss" since the cached value has been evicted
5590
response = client.get("/cache_expires")
5691
assert response.status_code == 200
57-
assert response.json() == {"success": True, "message": "this data should be cached for eight seconds"}
92+
assert response.json() == {"success": True, "message": "this data should be cached for five seconds"}
5893
assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss"
5994
assert "cache-control" in response.headers
6095
assert "expires" in response.headers
6196
assert "etag" in response.headers
97+
98+
# Check eTag value again. Since data is the same, the value should still match
6299
assert response.headers["etag"] == check_etag
63100

64101

65102
def test_cache_json_encoder():
103+
# In order to verify that our custom BetterJsonEncoder is working correctly, the /cache_json_encoder
104+
# endpoint returns a dict containing datetime.datetime, datetime.date and decimal.Decimal objects.
66105
response = client.get("/cache_json_encoder")
67106
assert response.status_code == 200
68107
response_json = response.json()
@@ -75,13 +114,18 @@ def test_cache_json_encoder():
75114
"val": "3.140000000000000124344978758017532527446746826171875",
76115
},
77116
}
117+
118+
# To verify that our custom object_hook function which deserializes types that are not typically
119+
# JSON-serializable is working correctly, we test it with the serialized values sent in the response.
78120
json_dict = deserialize_json(json.dumps(response_json))
79121
assert json_dict["start_time"] == datetime(2021, 4, 20, 7, 17, 17)
80122
assert json_dict["finish_by"] == datetime(2021, 4, 21)
81123
assert json_dict["final_calc"] == Decimal(3.14)
82124

83125

84126
def test_cache_control_no_cache():
127+
# Simple test that verifies if a request is recieved with the cache-control header field containing "no-cache",
128+
# no caching behavior is performed
85129
response = client.get("/cache_never_expire", headers={"cache-control": "no-cache"})
86130
assert response.status_code == 200
87131
assert response.json() == {"success": True, "message": "this data can be cached indefinitely"}
@@ -92,6 +136,8 @@ def test_cache_control_no_cache():
92136

93137

94138
def test_cache_control_no_store():
139+
# Simple test that verifies if a request is recieved with the cache-control header field containing "no-store",
140+
# no caching behavior is performed
95141
response = client.get("/cache_never_expire", headers={"cache-control": "no-store"})
96142
assert response.status_code == 200
97143
assert response.json() == {"success": True, "message": "this data can be cached indefinitely"}
@@ -102,19 +148,39 @@ def test_cache_control_no_store():
102148

103149

104150
def test_if_none_match():
151+
# Initial request, response data is added to cache
105152
response = client.get("/cache_never_expire")
106153
assert response.status_code == 200
107154
assert response.json() == {"success": True, "message": "this data can be cached indefinitely"}
108155
assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss"
109156
assert "cache-control" in response.headers
110157
assert "expires" in response.headers
111158
assert "etag" in response.headers
159+
160+
# Store correct eTag value from response header
112161
etag = response.headers["etag"]
162+
# Create another eTag value that is different from the correct value
113163
invalid_etag = "W/-5480454928453453778"
164+
165+
# Send request to same endpoint where If-None-Match header contains both valid and invalid eTag values
114166
response = client.get("/cache_never_expire", headers={"if-none-match": f"{etag}, {invalid_etag}"})
115167
assert response.status_code == 304
168+
assert not response.content
169+
assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Hit"
170+
assert "cache-control" in response.headers
171+
assert "expires" in response.headers
172+
assert "etag" in response.headers
173+
174+
# Send request to same endpoint where If-None-Match header contains just the wildcard (*) character
116175
response = client.get("/cache_never_expire", headers={"if-none-match": "*"})
117176
assert response.status_code == 304
177+
assert not response.content
178+
assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Hit"
179+
assert "cache-control" in response.headers
180+
assert "expires" in response.headers
181+
assert "etag" in response.headers
182+
183+
# Send request to same endpoint where If-None-Match header contains only the invalid eTag value
118184
response = client.get("/cache_never_expire", headers={"if-none-match": invalid_etag})
119185
assert response.status_code == 200
120186
assert response.json() == {"success": True, "message": "this data can be cached indefinitely"}
@@ -125,6 +191,8 @@ def test_if_none_match():
125191

126192

127193
def test_partial_cache_one_hour():
194+
# Simple test that verifies that the @cache_for_one_hour partial function version of the @cache decorator
195+
# is working correctly.
128196
response = client.get("/cache_one_hour")
129197
assert response.status_code == 200
130198
assert response.json() == {"success": True, "message": "this data should be cached for one hour"}
@@ -134,3 +202,15 @@ def test_partial_cache_one_hour():
134202
assert match and int(match.groupdict()["ttl"]) == 3600
135203
assert "expires" in response.headers
136204
assert "etag" in response.headers
205+
206+
207+
def test_cache_invalid_type():
208+
# Simple test that verifies the correct behavior when a value that is not JSON-serializable is returned
209+
# as response data
210+
with pytest.raises(ValueError):
211+
response = client.get("/cache_invalid_type")
212+
assert response.status_code == 200
213+
assert "x-fastapi-cache" not in response.headers
214+
assert "cache-control" not in response.headers
215+
assert "expires" not in response.headers
216+
assert "etag" not in response.headers

0 commit comments

Comments
 (0)