├── .github ├── pr-labeler.yml ├── release-drafter.yml └── workflows │ ├── pr-labeler.yml │ ├── publish-pypi.yml │ ├── release-drafter.yml │ └── tox.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── src └── fastapi_redis_cache │ ├── __init__.py │ ├── cache.py │ ├── client.py │ ├── enums.py │ ├── key_gen.py │ ├── redis.py │ ├── types.py │ ├── util.py │ └── version.py ├── tests ├── __init__.py ├── conftest.py ├── main.py └── test_cache.py └── tox.ini /.github/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | feature: feature/* 2 | enhancement: enhancement/* 3 | bug-fix: ["bug-fix/*", "fix/*"] 4 | refactoring: refactoring/* 5 | chore: chore/* 6 | dependencies: ["bump/*", "Bump *"] 7 | patch: patch-release/* 8 | minor: minor-release/* 9 | major: major-release/* 10 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "New Features/Enhancements" 5 | labels: 6 | - "feature" 7 | - "enhancement" 8 | - title: "Bug Fixes" 9 | labels: 10 | - "bug-fix" 11 | - title: "Maintenance" 12 | labels: 13 | - "refactoring" 14 | - "chore" 15 | - title: "Dependency Updates" 16 | labels: 17 | - "dependencies" 18 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 19 | no-changes-template: "* No changes" 20 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 21 | version-resolver: 22 | major: 23 | labels: 24 | - "major" 25 | minor: 26 | labels: 27 | - "minor" 28 | patch: 29 | labels: 30 | - "patch" 31 | default: patch 32 | template: | 33 | $CHANGES 34 | -------------------------------------------------------------------------------- /.github/workflows/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | name: PR Labeler 2 | on: 3 | pull_request: 4 | types: [opened] 5 | 6 | jobs: 7 | pr-labeler: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: TimonVS/pr-labeler-action@v3 11 | with: 12 | configuration-path: .github/pr-labeler.yml # optional, .github/pr-labeler.yml is the default value 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/publish-pypi.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publish to PyPI 3 | 4 | on: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: "3.x" 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install setuptools wheel twine 22 | - name: Build and publish 23 | env: 24 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 25 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 26 | run: | 27 | python setup.py bdist_wheel 28 | python setup.py sdist 29 | twine upload dist/* 30 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | update_release_draft: 9 | runs-on: ubuntu-latest 10 | steps: 11 | # Drafts your next Release notes as Pull Requests are merged into "master" 12 | - uses: release-drafter/release-drafter@v5.11.0 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: ["main"] 7 | pull_request: 8 | branches: ["main"] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | tests: 13 | name: "Python ${{ matrix.python-version }}" 14 | runs-on: "ubuntu-latest" 15 | env: 16 | USING_COVERAGE: "3.9" 17 | 18 | strategy: 19 | matrix: 20 | python-version: ["3.7", "3.8", "3.9"] 21 | 22 | steps: 23 | - uses: "actions/checkout@v2" 24 | - uses: "actions/setup-python@v2" 25 | with: 26 | python-version: "${{ matrix.python-version }}" 27 | - name: "Install dependencies" 28 | run: | 29 | set -xe 30 | python -VV 31 | python -m site 32 | python -m pip install --upgrade pip setuptools wheel 33 | python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions 34 | - name: "Run tox targets for ${{ matrix.python-version }}" 35 | run: "python -m tox" 36 | - name: Upload Coverage Report To Code Climate 37 | uses: paambaati/codeclimate-action@v2.6.0 38 | env: 39 | CC_TEST_REPORTER_ID: 30e873c5c03b343557ede56965b7e94146a21b8cbe357569f6a45365f6afaaa3 40 | with: 41 | coverageCommand: python -m coverage xml 42 | - name: "Upload coverage to Codecov" 43 | uses: "codecov/codecov-action@v1" 44 | with: 45 | fail_ci_if_error: true 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | build 3 | dist 4 | coverage_html 5 | .vscode 6 | .tox 7 | .coverage 8 | .env 9 | .python-version 10 | __pycache__/ 11 | *.py[cod] 12 | .pytest_cache/ 13 | *.egg-info/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Aaron Luna 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. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune tests 2 | prune .github 3 | exclude .env 4 | exclude .gitignore 5 | exclude .python-version 6 | include src/vigorish/setup/csv/*.csv 7 | include src/vigorish/setup/json/*.json 8 | include src/vigorish/nightmarejs/list_functions.js 9 | include src/vigorish/nightmarejs/new_nightmare.js 10 | include src/vigorish/nightmarejs/scrape_job.js 11 | include src/vigorish/nightmarejs/scrape_urls.js 12 | include src/vigorish/nightmarejs/package.json 13 | global-exclude *.py[cod] 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## fastapi-redis-cache 2 | 3 | [![PyPI version](https://badge.fury.io/py/fastapi-redis-cache.svg)](https://badge.fury.io/py/fastapi-redis-cache) 4 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/fastapi-redis-cache?color=%234DC71F) 5 | ![PyPI - License](https://img.shields.io/pypi/l/fastapi-redis-cache?color=%25234DC71F) 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/fastapi-redis-cache) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/ec0b1d7afb21bd8c23dc/maintainability)](https://codeclimate.com/github/a-luna/fastapi-redis-cache/maintainability) 8 | [![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) 9 | 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 | 21 | ## Features 22 | 23 | - Cache response data for async and non-async path operation functions. 24 | - Lifetime of cached data is configured separately for each API endpoint. 25 | - Requests with `Cache-Control` header containing `no-cache` or `no-store` are handled correctly (all caching behavior is disabled). 26 | - Requests with `If-None-Match` header will receive a response with status `304 NOT MODIFIED` if `ETag` for requested resource matches header value. 27 | 28 | ## Installation 29 | 30 | `pip install fastapi-redis-cache` 31 | 32 | ## Usage 33 | 34 | ### Initialize Redis 35 | 36 | 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: 37 | 38 | ```python {linenos=table} 39 | import os 40 | 41 | from fastapi import FastAPI, Request, Response 42 | from fastapi_redis_cache import FastApiRedisCache, cache 43 | from sqlalchemy.orm import Session 44 | 45 | LOCAL_REDIS_URL = "redis://127.0.0.1:6379" 46 | 47 | app = FastAPI(title="FastAPI Redis Cache Example") 48 | 49 | @app.on_event("startup") 50 | def startup(): 51 | redis_cache = FastApiRedisCache() 52 | redis_cache.init( 53 | host_url=os.environ.get("REDIS_URL", LOCAL_REDIS_URL), 54 | prefix="myapi-cache", 55 | response_header="X-MyAPI-Cache", 56 | ignore_arg_types=[Request, Response, Session] 57 | ) 58 | ``` 59 | 60 | 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: 61 | 62 | - `host_url` (`str`) — Redis database URL. (_**Required**_) 63 | - `prefix` (`str`) — Prefix to add to every cache key stored in the Redis database. (_Optional_, defaults to `None`) 64 | - `response_header` (`str`) — Name of the custom header field used to identify cache hits/misses. (_Optional_, defaults to `X-FastAPI-Cache`) 65 | - `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]`) 66 | - 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)). 67 | 68 | ### `@cache` Decorator 69 | 70 | Decorating a path function with `@cache` enables caching for the endpoint. **Response data is only cached for `GET` operations**, decorating path functions for other HTTP method types will have no effect. If no arguments are provided, responses will be set to expire after one year, which, historically, is the correct way to mark data that "never expires". 71 | 72 | ```python 73 | # WILL NOT be cached 74 | @app.get("/data_no_cache") 75 | def get_data(): 76 | return {"success": True, "message": "this data is not cacheable, for... you know, reasons"} 77 | 78 | # Will be cached for one year 79 | @app.get("/immutable_data") 80 | @cache() 81 | async def get_immutable_data(): 82 | return {"success": True, "message": "this data can be cached indefinitely"} 83 | ``` 84 | 85 | 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: 86 | 87 | ```console 88 | INFO:fastapi_redis_cache:| 04/21/2021 12:26:26 AM | CONNECT_BEGIN: Attempting to connect to Redis server... 89 | INFO:fastapi_redis_cache:| 04/21/2021 12:26:26 AM | CONNECT_SUCCESS: Redis client is connected to server. 90 | INFO:fastapi_redis_cache:| 04/21/2021 12:26:34 AM | KEY_ADDED_TO_CACHE: key=api.get_immutable_data() 91 | INFO: 127.0.0.1:61779 - "GET /immutable_data HTTP/1.1" 200 OK 92 | INFO:fastapi_redis_cache:| 04/21/2021 12:26:45 AM | KEY_FOUND_IN_CACHE: key=api.get_immutable_data() 93 | INFO: 127.0.0.1:61779 - "GET /immutable_data HTTP/1.1" 200 OK 94 | ``` 95 | 96 | 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. 97 | 98 | In most situations, response data must expire in a much shorter period of time than one year. Using the `expire` parameter, You can specify the number of seconds before data is deleted: 99 | 100 | ```python 101 | # Will be cached for thirty seconds 102 | @app.get("/dynamic_data") 103 | @cache(expire=30) 104 | def get_dynamic_data(request: Request, response: Response): 105 | return {"success": True, "message": "this data should only be cached temporarily"} 106 | ``` 107 | 108 | > **NOTE!** `expire` can be either an `int` value or `timedelta` object. When the TTL is very short (like the example above) this results in a decorator that is expressive and requires minimal effort to parse visually. For durations an hour or longer (e.g., `@cache(expire=86400)`), IMHO, using a `timedelta` object is much easier to grok (`@cache(expire=timedelta(days=1))`). 109 | 110 | #### Response Headers 111 | 112 | A response from the `/dynamic_data` endpoint showing all header values is given below: 113 | 114 | ```console 115 | $ http "http://127.0.0.1:8000/dynamic_data" 116 | HTTP/1.1 200 OK 117 | cache-control: max-age=29 118 | content-length: 72 119 | content-type: application/json 120 | date: Wed, 21 Apr 2021 07:54:33 GMT 121 | etag: W/-5480454928453453778 122 | expires: Wed, 21 Apr 2021 07:55:03 GMT 123 | server: uvicorn 124 | x-fastapi-cache: Hit 125 | 126 | { 127 | "message": "this data should only be cached temporarily", 128 | "success": true 129 | } 130 | ``` 131 | 132 | - The `x-fastapi-cache` header field indicates that this response was found in the Redis cache (a.k.a. a `Hit`). The only other possible value for this field is `Miss`. 133 | - 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=30` was specified in the `@cache` decorator. 134 | - The `etag` field is an identifier that is created by converting the response data to a string and applying a hash function. If a request containing the `if-none-match` header is received, any `etag` value(s) included in the request will be used to determine if the data requested is the same as the data stored in the cache. If they are the same, a `304 NOT MODIFIED` response will be sent. If they are not the same, the cached data will be sent with a `200 OK` response. 135 | 136 | These header fields are used by your web browser's cache to avoid sending unnecessary requests. After receiving the response shown above, if a user requested the same resource before the `expires` time, the browser wouldn't send a request to the FastAPI server. Instead, the cached response would be served directly from disk. 137 | 138 | Of course, this assumes that the browser is configured to perform caching. If the browser sends a request with the `cache-control` header containing `no-cache` or `no-store`, the `cache-control`, `etag`, `expires`, and `x-fastapi-cache` response header fields will not be included and the response data will not be stored in Redis. 139 | 140 | #### Pre-defined Lifetimes 141 | 142 | The decorators listed below define several common durations and can be used in place of the `@cache` decorator: 143 | 144 | - `@cache_one_minute` 145 | - `@cache_one_hour` 146 | - `@cache_one_day` 147 | - `@cache_one_week` 148 | - `@cache_one_month` 149 | - `@cache_one_year` 150 | 151 | For example, instead of `@cache(expire=timedelta(days=1))`, you could use: 152 | 153 | ```python 154 | from fastapi_redis_cache import cache_one_day 155 | 156 | @app.get("/cache_one_day") 157 | @cache_one_day() 158 | def partial_cache_one_day(response: Response): 159 | return {"success": True, "message": "this data should be cached for 24 hours"} 160 | ``` 161 | 162 | If a duration that you would like to use throughout your project is missing from the list, you can easily create your own: 163 | 164 | ```python 165 | from functools import partial, update_wrapper 166 | from fastapi_redis_cache import cache 167 | 168 | ONE_HOUR_IN_SECONDS = 3600 169 | 170 | cache_two_hours = partial(cache, expire=ONE_HOUR_IN_SECONDS * 2) 171 | update_wrapper(cache_two_hours, cache) 172 | ``` 173 | 174 | Then, simply import `cache_two_hours` and use it to decorate your API endpoint path functions: 175 | 176 | ```python 177 | @app.get("/cache_two_hours") 178 | @cache_two_hours() 179 | def partial_cache_two_hours(response: Response): 180 | return {"success": True, "message": "this data should be cached for two hours"} 181 | ``` 182 | 183 | ### Cache Keys 184 | 185 | Consider the `/get_user` API route defined below. This is the first path function we have seen where the response depends on the value of an argument (`id: int`). This is a typical CRUD operation where `id` is used to retrieve a `User` record from a database. 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): 186 | 187 | ```python 188 | @app.get("/get_user", response_model=schemas.User) 189 | @cache(expire=3600) 190 | def get_user(id: int, db: Session = Depends(get_db)): 191 | return db.query(models.User).filter(models.User.id == id).first() 192 | ``` 193 | 194 | 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? 195 | 196 | Before we can answer that question, we must understand how a cache key is created. If the following request was received: `GET /get_user?id=1`, the cache key generated would be `myapi-cache:api.get_user(id=1)`. 197 | 198 | The source of each value used to construct this cache key is given below: 199 | 200 | 1) The optional `prefix` value provided as an argument to the `FastApiRedisCache.init` method => `"myapi-cache"`. 201 | 2) The module containing the path function => `"api"`. 202 | 3) The name of the path function => `"get_user"`. 203 | 4) The name and value of all arguments to the path function **EXCEPT for arguments with a type that exists in** `ignore_arg_types` => `"id=1"`. 204 | 205 | Since `Session` is included in `ignore_arg_types`, the `db` argument was not included in the cache key when **Step 4** was performed. 206 | 207 | If `Session` had not been included in `ignore_arg_types`, 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: 208 | 209 | ```console 210 | INFO:uvicorn.error:Application startup complete. 211 | INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db=) 212 | INFO: 127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OK 213 | INFO:fastapi_redis_cache.client: 04/23/2021 07:04:15 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db=) 214 | INFO: 127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OK 215 | INFO:fastapi_redis_cache.client: 04/23/2021 07:04:17 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db=) 216 | INFO: 127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OK 217 | ``` 218 | 219 | The log messages indicate that three requests were received for the same endpoint, with the same arguments (`GET /get_user?id=1`). However, the cache key that is created is different for each request: 220 | 221 | ```console 222 | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db= 223 | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db= 224 | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1,db= 225 | ``` 226 | 227 | 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. 228 | 229 | The correct behavior (with `Session` included in `ignore_arg_types`) is shown below: 230 | 231 | ```console 232 | INFO:uvicorn.error:Application startup complete. 233 | INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_ADDED_TO_CACHE: key=myapi-cache:api.get_user(id=1) 234 | INFO: 127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OK 235 | INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_FOUND_IN_CACHE: key=myapi-cache:api.get_user(id=1) 236 | INFO: 127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OK 237 | INFO:fastapi_redis_cache.client: 04/23/2021 07:04:12 PM | KEY_FOUND_IN_CACHE: key=myapi-cache:api.get_user(id=1) 238 | INFO: 127.0.0.1:50761 - "GET /get_user?id=1 HTTP/1.1" 200 OK 239 | ``` 240 | 241 | Now, every request for the same `id` generates the same key value (`myapi-cache:api.get_user(id=1)`). As expected, the first request adds the key/value pair to the cache, and each subsequent request retrieves the value from the cache based on the key. 242 | 243 | ### Cache Keys Pt 2. 244 | 245 | What about this situation? You create a custom dependency for your API that performs input validation, but you can't ignore it because _**it does**_ have an effect on the response data. There's a simple solution for that, too. 246 | 247 | Here is an endpoint from one of my projects: 248 | 249 | ```python 250 | @router.get("/scoreboard", response_model=ScoreboardSchema) 251 | @cache() 252 | def get_scoreboard_for_date( 253 | game_date: MLBGameDate = Depends(), db: Session = Depends(get_db) 254 | ): 255 | return get_scoreboard_data_for_date(db, game_date.date) 256 | ``` 257 | 258 | The `game_date` argument is a `MLBGameDate` type. This is a custom type that parses the value from the querystring to a date, and determines if the parsed date is valid by checking if it is within a certain range. The implementation for `MLBGameDate` is given below: 259 | 260 | ```python 261 | class MLBGameDate: 262 | def __init__( 263 | self, 264 | game_date: str = Query(..., description="Date as a string in YYYYMMDD format"), 265 | db: Session = Depends(get_db), 266 | ): 267 | try: 268 | parsed_date = parse_date(game_date) 269 | except ValueError as ex: 270 | raise HTTPException(status_code=400, detail=ex.message) 271 | result = Season.is_date_in_season(db, parsed_date) 272 | if result.failure: 273 | raise HTTPException(status_code=400, detail=result.error) 274 | self.date = parsed_date 275 | self.season = convert_season_to_dict(result.value) 276 | 277 | def __str__(self): 278 | return self.date.strftime("%Y-%m-%d") 279 | ``` 280 | 281 | Please note the `__str__` method that overrides the default behavior. This way, instead of ``, the value will be formatted as, for example, `2019-05-09`. You can use this strategy whenever you have an argument that has en effect on the response data but converting that argument to a string results in a value containing the object's memory location. 282 | 283 | ## Questions/Contributions 284 | 285 | 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. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 40.6.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 120 7 | target-version = ['py38'] 8 | include = '\.pyi?$' 9 | exclude = ''' 10 | /( 11 | \.eggs 12 | | \.git 13 | | \.hg 14 | | \.mypy_cache 15 | | \.pytest_cache 16 | | \.tox 17 | | \.vscode 18 | | __pycache__ 19 | | _build 20 | | buck-out 21 | | build 22 | | dist 23 | | venv 24 | )/ 25 | ''' 26 | 27 | [tool.isort] 28 | ensure_newline_before_comments = true 29 | skip_gitignore = true 30 | line_length = 100 31 | multi_line_output = 3 32 | use_parentheses = true 33 | include_trailing_comma = true 34 | force_alphabetical_sort_within_sections = true 35 | color_output = true 36 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black==20.8b1 2 | coverage==5.5 3 | fakeredis==1.5.2 4 | flake8==3.9.2 5 | isort==5.9.1 6 | pytest==6.2.4 7 | pytest-cov==2.12.1 8 | pytest-flake8==1.0.7 9 | pytest-random-order==1.0.4 10 | requests==2.25.1 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.65.2 2 | pydantic==1.8.2 3 | redis==3.5.3 4 | uvicorn==0.14.0 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = 3 | # generate report with details of all (non-pass) test results 4 | -ra 5 | # show local variables in tracebacks 6 | --showlocals 7 | # report linting issues with flake8 8 | --flake8 9 | # collect code coverage metrics 10 | --cov fastapi_redis_cache 11 | # verbose output 12 | --verbose 13 | # clear cache before each run 14 | --cache-clear 15 | norecursedirs = 16 | .git 17 | .pytest_cache 18 | .vscode 19 | venv 20 | build 21 | dist 22 | custom_scripts 23 | 24 | [flake8] 25 | max-line-length = 120 26 | select = 27 | B, 28 | C, 29 | E, 30 | F, 31 | W, 32 | T4, 33 | B9 34 | ignore = 35 | E203, 36 | E231, 37 | E266, 38 | E501, 39 | FS003, 40 | W503 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | """Installation script for fastapi-redis-cache.""" 3 | from pathlib import Path 4 | 5 | from setuptools import find_packages, setup 6 | 7 | DESCRIPTION = "A simple and robust caching solution for FastAPI endpoints, fueled by the unfathomable power of Redis." 8 | APP_ROOT = Path(__file__).parent 9 | README = (APP_ROOT / "README.md").read_text().strip() 10 | AUTHOR = "Aaron Luna" 11 | AUTHOR_EMAIL = "contact@aaronluna.dev" 12 | PROJECT_URLS = { 13 | "Bug Tracker": "https://github.com/a-luna/fastapi-redis-cache/issues", 14 | "Source Code": "https://github.com/a-luna/fastapi-redis-cache", 15 | } 16 | CLASSIFIERS = [ 17 | "Development Status :: 4 - Beta", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Natural Language :: English", 21 | "Operating System :: MacOS :: MacOS X", 22 | "Operating System :: POSIX :: Linux", 23 | "Operating System :: Unix", 24 | "Programming Language :: Python :: 3.7", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3 :: Only", 28 | ] 29 | INSTALL_REQUIRES = [ 30 | "fastapi", 31 | "pydantic", 32 | "python-dateutil", 33 | "redis", 34 | ] 35 | DEV_REQUIRES = [ 36 | "black", 37 | "coverage", 38 | "fakeredis", 39 | "flake8", 40 | "isort", 41 | "pytest", 42 | "pytest-cov", 43 | "pytest-flake8", 44 | "pytest-random-order", 45 | "requests", 46 | ] 47 | 48 | exec(open(str(APP_ROOT / "src/fastapi_redis_cache/version.py")).read()) 49 | setup( 50 | name="fastapi-redis-cache", 51 | description=DESCRIPTION, 52 | long_description=README, 53 | long_description_content_type="text/markdown", 54 | version=__version__, 55 | author=AUTHOR, 56 | author_email=AUTHOR_EMAIL, 57 | maintainer=AUTHOR, 58 | maintainer_email=AUTHOR_EMAIL, 59 | license="MIT", 60 | url=PROJECT_URLS["Source Code"], 61 | project_urls=PROJECT_URLS, 62 | packages=find_packages(where="src"), 63 | package_dir={"": "src"}, 64 | include_package_data=True, 65 | python_requires=">=3.7", 66 | classifiers=CLASSIFIERS, 67 | install_requires=INSTALL_REQUIRES, 68 | extras_require={"dev": DEV_REQUIRES}, 69 | ) 70 | -------------------------------------------------------------------------------- /src/fastapi_redis_cache/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from fastapi_redis_cache.cache import ( 3 | cache, 4 | cache_one_day, 5 | cache_one_hour, 6 | cache_one_minute, 7 | cache_one_month, 8 | cache_one_week, 9 | cache_one_year, 10 | ) 11 | from fastapi_redis_cache.client import FastApiRedisCache 12 | -------------------------------------------------------------------------------- /src/fastapi_redis_cache/cache.py: -------------------------------------------------------------------------------- 1 | """cache.py""" 2 | import asyncio 3 | from datetime import timedelta 4 | from functools import partial, update_wrapper, wraps 5 | from http import HTTPStatus 6 | from typing import Union 7 | 8 | from fastapi import Response 9 | 10 | from fastapi_redis_cache.client import FastApiRedisCache 11 | from fastapi_redis_cache.util import ( 12 | deserialize_json, 13 | ONE_DAY_IN_SECONDS, 14 | ONE_HOUR_IN_SECONDS, 15 | ONE_MONTH_IN_SECONDS, 16 | ONE_WEEK_IN_SECONDS, 17 | ONE_YEAR_IN_SECONDS, 18 | serialize_json, 19 | ) 20 | 21 | 22 | def cache(*, expire: Union[int, timedelta] = ONE_YEAR_IN_SECONDS): 23 | """Enable caching behavior for the decorated function. 24 | 25 | Args: 26 | expire (Union[int, timedelta], optional): The number of seconds 27 | from now when the cached response should expire. Defaults to 31,536,000 28 | seconds (i.e., the number of seconds in one year). 29 | """ 30 | 31 | def outer_wrapper(func): 32 | @wraps(func) 33 | async def inner_wrapper(*args, **kwargs): 34 | """Return cached value if one exists, otherwise evaluate the wrapped function and cache the result.""" 35 | 36 | func_kwargs = kwargs.copy() 37 | request = func_kwargs.pop("request", None) 38 | response = func_kwargs.pop("response", None) 39 | create_response_directly = not response 40 | if create_response_directly: 41 | response = Response() 42 | redis_cache = FastApiRedisCache() 43 | if redis_cache.not_connected or redis_cache.request_is_not_cacheable(request): 44 | # if the redis client is not connected or request is not cacheable, no caching behavior is performed. 45 | return await get_api_response_async(func, *args, **kwargs) 46 | key = redis_cache.get_cache_key(func, *args, **kwargs) 47 | ttl, in_cache = redis_cache.check_cache(key) 48 | if in_cache: 49 | redis_cache.set_response_headers(response, True, deserialize_json(in_cache), ttl) 50 | if redis_cache.requested_resource_not_modified(request, in_cache): 51 | response.status_code = int(HTTPStatus.NOT_MODIFIED) 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 | ) 67 | response_data = await get_api_response_async(func, *args, **kwargs) 68 | ttl = calculate_ttl(expire) 69 | cached = redis_cache.add_to_cache(key, response_data, ttl) 70 | if cached: 71 | redis_cache.set_response_headers(response, cache_hit=False, response_data=response_data, ttl=ttl) 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 78 | ) 79 | return response_data 80 | 81 | return inner_wrapper 82 | 83 | return outer_wrapper 84 | 85 | 86 | async def get_api_response_async(func, *args, **kwargs): 87 | """Helper function that allows decorator to work with both async and non-async functions.""" 88 | return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs) 89 | 90 | 91 | def calculate_ttl(expire: Union[int, timedelta]) -> int: 92 | """"Converts expire time to total seconds and ensures that ttl is capped at one year.""" 93 | if isinstance(expire, timedelta): 94 | expire = int(expire.total_seconds()) 95 | return min(expire, ONE_YEAR_IN_SECONDS) 96 | 97 | 98 | cache_one_minute = partial(cache, expire=60) 99 | cache_one_hour = partial(cache, expire=ONE_HOUR_IN_SECONDS) 100 | cache_one_day = partial(cache, expire=ONE_DAY_IN_SECONDS) 101 | cache_one_week = partial(cache, expire=ONE_WEEK_IN_SECONDS) 102 | cache_one_month = partial(cache, expire=ONE_MONTH_IN_SECONDS) 103 | cache_one_year = partial(cache, expire=ONE_YEAR_IN_SECONDS) 104 | 105 | update_wrapper(cache_one_minute, cache) 106 | update_wrapper(cache_one_hour, cache) 107 | update_wrapper(cache_one_day, cache) 108 | update_wrapper(cache_one_week, cache) 109 | update_wrapper(cache_one_month, cache) 110 | update_wrapper(cache_one_year, cache) 111 | -------------------------------------------------------------------------------- /src/fastapi_redis_cache/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta 3 | from typing import Callable, Dict, List, Optional, Tuple, Type, Union 4 | 5 | from fastapi import Request, Response 6 | from redis import client 7 | 8 | from fastapi_redis_cache.enums import RedisEvent, RedisStatus 9 | from fastapi_redis_cache.key_gen import get_cache_key 10 | from fastapi_redis_cache.redis import redis_connect 11 | from fastapi_redis_cache.util import serialize_json 12 | 13 | DEFAULT_RESPONSE_HEADER = "X-FastAPI-Cache" 14 | ALLOWED_HTTP_TYPES = ["GET"] 15 | LOG_TIMESTAMP = "%m/%d/%Y %I:%M:%S %p" 16 | HTTP_TIME = "%a, %d %b %Y %H:%M:%S GMT" 17 | 18 | logging.basicConfig() 19 | logger = logging.getLogger(__name__) 20 | logger.setLevel(logging.INFO) 21 | 22 | 23 | class MetaSingleton(type): 24 | """Metaclass for creating classes that allow only a single instance to be created.""" 25 | 26 | _instances = {} 27 | 28 | def __call__(cls, *args, **kwargs): 29 | if cls not in cls._instances: 30 | cls._instances[cls] = super().__call__(*args, **kwargs) 31 | return cls._instances[cls] 32 | 33 | 34 | class FastApiRedisCache(metaclass=MetaSingleton): 35 | """Communicates with Redis server to cache API response data.""" 36 | 37 | host_url: str 38 | prefix: str = None 39 | response_header: str = None 40 | status: RedisStatus = RedisStatus.NONE 41 | redis: client.Redis = None 42 | 43 | @property 44 | def connected(self): 45 | return self.status == RedisStatus.CONNECTED 46 | 47 | @property 48 | def not_connected(self): 49 | return not self.connected 50 | 51 | def init( 52 | self, 53 | host_url: str, 54 | prefix: Optional[str] = None, 55 | response_header: Optional[str] = None, 56 | ignore_arg_types: Optional[List[Type[object]]] = None, 57 | ) -> None: 58 | """Connect to a Redis database using `host_url` and configure cache settings. 59 | 60 | Args: 61 | host_url (str): URL for a Redis database. 62 | prefix (str, optional): Prefix to add to every cache key stored in the 63 | Redis database. Defaults to None. 64 | response_header (str, optional): Name of the custom header field used to 65 | identify cache hits/misses. Defaults to None. 66 | ignore_arg_types (List[Type[object]], optional): Each argument to the 67 | API endpoint function is used to compose the cache key. If there 68 | are any arguments that have no effect on the response (such as a 69 | `Request` or `Response` object), including their type in this list 70 | will ignore those arguments when the key is created. Defaults to None. 71 | """ 72 | self.host_url = host_url 73 | self.prefix = prefix 74 | self.response_header = response_header or DEFAULT_RESPONSE_HEADER 75 | self.ignore_arg_types = ignore_arg_types 76 | self._connect() 77 | 78 | def _connect(self): 79 | self.log(RedisEvent.CONNECT_BEGIN, msg="Attempting to connect to Redis server...") 80 | self.status, self.redis = redis_connect(self.host_url) 81 | if self.status == RedisStatus.CONNECTED: 82 | self.log(RedisEvent.CONNECT_SUCCESS, msg="Redis client is connected to server.") 83 | if self.status == RedisStatus.AUTH_ERROR: # pragma: no cover 84 | self.log(RedisEvent.CONNECT_FAIL, msg="Unable to connect to redis server due to authentication error.") 85 | if self.status == RedisStatus.CONN_ERROR: # pragma: no cover 86 | self.log(RedisEvent.CONNECT_FAIL, msg="Redis server did not respond to PING message.") 87 | 88 | def request_is_not_cacheable(self, request: Request) -> bool: 89 | return request and ( 90 | request.method not in ALLOWED_HTTP_TYPES 91 | or any(directive in request.headers.get("Cache-Control", "") for directive in ["no-store", "no-cache"]) 92 | ) 93 | 94 | def get_cache_key(self, func: Callable, *args: List, **kwargs: Dict) -> str: 95 | return get_cache_key(self.prefix, self.ignore_arg_types, func, *args, **kwargs) 96 | 97 | def check_cache(self, key: str) -> Tuple[int, str]: 98 | pipe = self.redis.pipeline() 99 | ttl, in_cache = pipe.ttl(key).get(key).execute() 100 | if in_cache: 101 | self.log(RedisEvent.KEY_FOUND_IN_CACHE, key=key) 102 | return (ttl, in_cache) 103 | 104 | def requested_resource_not_modified(self, request: Request, cached_data: str) -> bool: 105 | if not request or "If-None-Match" not in request.headers: 106 | return False 107 | check_etags = [etag.strip() for etag in request.headers["If-None-Match"].split(",") if etag] 108 | if len(check_etags) == 1 and check_etags[0] == "*": 109 | return True 110 | return self.get_etag(cached_data) in check_etags 111 | 112 | def add_to_cache(self, key: str, value: Dict, expire: int) -> bool: 113 | response_data = None 114 | try: 115 | response_data = serialize_json(value) 116 | except TypeError: 117 | message = f"Object of type {type(value)} is not JSON-serializable" 118 | self.log(RedisEvent.FAILED_TO_CACHE_KEY, msg=message, key=key) 119 | return False 120 | cached = self.redis.set(name=key, value=response_data, ex=expire) 121 | if cached: 122 | self.log(RedisEvent.KEY_ADDED_TO_CACHE, key=key) 123 | else: # pragma: no cover 124 | self.log(RedisEvent.FAILED_TO_CACHE_KEY, key=key, value=value) 125 | return cached 126 | 127 | def set_response_headers( 128 | self, response: Response, cache_hit: bool, response_data: Dict = None, ttl: int = None 129 | ) -> None: 130 | response.headers[self.response_header] = "Hit" if cache_hit else "Miss" 131 | expires_at = datetime.utcnow() + timedelta(seconds=ttl) 132 | response.headers["Expires"] = expires_at.strftime(HTTP_TIME) 133 | response.headers["Cache-Control"] = f"max-age={ttl}" 134 | response.headers["ETag"] = self.get_etag(response_data) 135 | if "last_modified" in response_data: # pragma: no cover 136 | response.headers["Last-Modified"] = response_data["last_modified"] 137 | 138 | def log(self, event: RedisEvent, msg: Optional[str] = None, key: Optional[str] = None, value: Optional[str] = None): 139 | """Log `RedisEvent` using the configured `Logger` object""" 140 | message = f" {self.get_log_time()} | {event.name}" 141 | if msg: 142 | message += f": {msg}" 143 | if key: 144 | message += f": key={key}" 145 | if value: # pragma: no cover 146 | message += f", value={value}" 147 | logger.info(message) 148 | 149 | @staticmethod 150 | def get_etag(cached_data: Union[str, bytes, Dict]) -> str: 151 | if isinstance(cached_data, bytes): 152 | cached_data = cached_data.decode() 153 | if not isinstance(cached_data, str): 154 | cached_data = serialize_json(cached_data) 155 | return f"W/{hash(cached_data)}" 156 | 157 | @staticmethod 158 | def get_log_time(): 159 | """Get a timestamp to include with a log message.""" 160 | return datetime.now().strftime(LOG_TIMESTAMP) 161 | -------------------------------------------------------------------------------- /src/fastapi_redis_cache/enums.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class RedisStatus(IntEnum): 5 | """Connection status for the redis client.""" 6 | 7 | NONE = 0 8 | CONNECTED = 1 9 | AUTH_ERROR = 2 10 | CONN_ERROR = 3 11 | 12 | 13 | class RedisEvent(IntEnum): 14 | """Redis client events.""" 15 | 16 | CONNECT_BEGIN = 1 17 | CONNECT_SUCCESS = 2 18 | CONNECT_FAIL = 3 19 | KEY_ADDED_TO_CACHE = 4 20 | KEY_FOUND_IN_CACHE = 5 21 | FAILED_TO_CACHE_KEY = 6 22 | -------------------------------------------------------------------------------- /src/fastapi_redis_cache/key_gen.py: -------------------------------------------------------------------------------- 1 | """cache.py""" 2 | from collections import OrderedDict 3 | from inspect import signature, Signature 4 | from typing import Any, Callable, Dict, List 5 | 6 | from fastapi import Request, Response 7 | 8 | from fastapi_redis_cache.types import ArgType, SigParameters 9 | 10 | ALWAYS_IGNORE_ARG_TYPES = [Response, Request] 11 | 12 | 13 | def get_cache_key(prefix: str, ignore_arg_types: List[ArgType], func: Callable, *args: List, **kwargs: Dict) -> str: 14 | """Ganerate a string that uniquely identifies the function and values of all arguments. 15 | 16 | Args: 17 | prefix (`str`): Customizable namespace value that will prefix all cache keys. 18 | ignore_arg_types (`List[ArgType]`): Each argument to the API endpoint function is 19 | used to compose the cache key by calling `str(arg)`. If there are any keys that 20 | should not be used in this way (i.e., because their value has no effect on the 21 | response, such as a `Request` or `Response` object) you can remove them from 22 | the cache key by including their type as a list item in ignore_key_types. 23 | func (`Callable`): Path operation function for an API endpoint. 24 | 25 | Returns: 26 | `str`: Unique identifier for `func`, `*args` and `**kwargs` that can be used as a 27 | Redis key to retrieve cached API response data. 28 | """ 29 | 30 | if not ignore_arg_types: 31 | ignore_arg_types = [] 32 | ignore_arg_types.extend(ALWAYS_IGNORE_ARG_TYPES) 33 | ignore_arg_types = list(set(ignore_arg_types)) 34 | prefix = f"{prefix}:" if prefix else "" 35 | 36 | sig = signature(func) 37 | sig_params = sig.parameters 38 | func_args = get_func_args(sig, *args, **kwargs) 39 | args_str = get_args_str(sig_params, func_args, ignore_arg_types) 40 | return f"{prefix}{func.__module__}.{func.__name__}({args_str})" 41 | 42 | 43 | def get_func_args(sig: Signature, *args: List, **kwargs: Dict) -> "OrderedDict[str, Any]": 44 | """Return a dict object containing the name and value of all function arguments.""" 45 | func_args = sig.bind(*args, **kwargs) 46 | func_args.apply_defaults() 47 | return func_args.arguments 48 | 49 | 50 | def get_args_str(sig_params: SigParameters, func_args: "OrderedDict[str, Any]", ignore_arg_types: List[ArgType]) -> str: 51 | """Return a string with the name and value of all args whose type is not included in `ignore_arg_types`""" 52 | return ",".join( 53 | f"{arg}={val}" for arg, val in func_args.items() if sig_params[arg].annotation not in ignore_arg_types 54 | ) 55 | -------------------------------------------------------------------------------- /src/fastapi_redis_cache/redis.py: -------------------------------------------------------------------------------- 1 | """redis.py""" 2 | import os 3 | from typing import Tuple 4 | 5 | import redis 6 | 7 | from fastapi_redis_cache.enums import RedisStatus 8 | 9 | 10 | def redis_connect(host_url: str) -> Tuple[RedisStatus, redis.client.Redis]: 11 | """Attempt to connect to `host_url` and return a Redis client instance if successful.""" 12 | return _connect(host_url) if os.environ.get("CACHE_ENV") != "TEST" else _connect_fake() 13 | 14 | 15 | def _connect(host_url: str) -> Tuple[RedisStatus, redis.client.Redis]: # pragma: no cover 16 | try: 17 | redis_client = redis.from_url(host_url) 18 | if redis_client.ping(): 19 | return (RedisStatus.CONNECTED, redis_client) 20 | return (RedisStatus.CONN_ERROR, None) 21 | except redis.AuthenticationError: 22 | return (RedisStatus.AUTH_ERROR, None) 23 | except redis.ConnectionError: 24 | return (RedisStatus.CONN_ERROR, None) 25 | 26 | 27 | def _connect_fake() -> Tuple[RedisStatus, redis.client.Redis]: 28 | from fakeredis import FakeRedis 29 | 30 | return (RedisStatus.CONNECTED, FakeRedis()) 31 | -------------------------------------------------------------------------------- /src/fastapi_redis_cache/types.py: -------------------------------------------------------------------------------- 1 | from inspect import Parameter 2 | from typing import Mapping, Type 3 | 4 | ArgType = Type[object] 5 | SigParameters = Mapping[str, Parameter] 6 | -------------------------------------------------------------------------------- /src/fastapi_redis_cache/util.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import date, datetime 3 | from decimal import Decimal 4 | 5 | from dateutil import parser 6 | 7 | DATETIME_AWARE = "%m/%d/%Y %I:%M:%S %p %z" 8 | DATE_ONLY = "%m/%d/%Y" 9 | 10 | ONE_HOUR_IN_SECONDS = 3600 11 | ONE_DAY_IN_SECONDS = ONE_HOUR_IN_SECONDS * 24 12 | ONE_WEEK_IN_SECONDS = ONE_DAY_IN_SECONDS * 7 13 | ONE_MONTH_IN_SECONDS = ONE_DAY_IN_SECONDS * 30 14 | ONE_YEAR_IN_SECONDS = ONE_DAY_IN_SECONDS * 365 15 | 16 | SERIALIZE_OBJ_MAP = { 17 | str(datetime): parser.parse, 18 | str(date): parser.parse, 19 | str(Decimal): Decimal, 20 | } 21 | 22 | 23 | class BetterJsonEncoder(json.JSONEncoder): 24 | def default(self, obj): 25 | if isinstance(obj, datetime): 26 | return {"val": obj.strftime(DATETIME_AWARE), "_spec_type": str(datetime)} 27 | elif isinstance(obj, date): 28 | return {"val": obj.strftime(DATE_ONLY), "_spec_type": str(date)} 29 | elif isinstance(obj, Decimal): 30 | return {"val": str(obj), "_spec_type": str(Decimal)} 31 | else: # pragma: no cover 32 | return super().default(obj) 33 | 34 | 35 | def object_hook(obj): 36 | if "_spec_type" not in obj: 37 | return obj 38 | _spec_type = obj["_spec_type"] 39 | if _spec_type not in SERIALIZE_OBJ_MAP: # pragma: no cover 40 | raise TypeError(f'"{obj["val"]}" (type: {_spec_type}) is not JSON serializable') 41 | return SERIALIZE_OBJ_MAP[_spec_type](obj["val"]) 42 | 43 | 44 | def serialize_json(json_dict): 45 | return json.dumps(json_dict, cls=BetterJsonEncoder) 46 | 47 | 48 | def deserialize_json(json_str): 49 | return json.loads(json_str, object_hook=object_hook) 50 | -------------------------------------------------------------------------------- /src/fastapi_redis_cache/version.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | __version_info__ = ("0", "2", "5") # pragma: no cover 3 | __version__ = ".".join(__version_info__) # pragma: no cover 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-luna/fastapi-redis-cache/233835b1a2265423bacb73c81d41c12676d2f7fc/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from fastapi_redis_cache import FastApiRedisCache 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def test_setup(request): 10 | """Setup TEST environment to use FakeRedis.""" 11 | os.environ["CACHE_ENV"] = "TEST" 12 | redis_cache = FastApiRedisCache() 13 | redis_cache.init(host_url="") 14 | return True 15 | -------------------------------------------------------------------------------- /tests/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import date, datetime, timedelta 3 | from decimal import Decimal 4 | 5 | from fastapi import FastAPI, Request, Response 6 | 7 | from fastapi_redis_cache import cache, cache_one_hour, cache_one_minute 8 | 9 | app = FastAPI(title="FastAPI Redis Cache Test App") 10 | 11 | 12 | @app.get("/cache_never_expire") 13 | @cache() 14 | def cache_never_expire(request: Request, response: Response): 15 | return {"success": True, "message": "this data can be cached indefinitely"} 16 | 17 | 18 | @app.get("/cache_expires") 19 | @cache(expire=timedelta(seconds=5)) 20 | async def cache_expires(): 21 | return {"success": True, "message": "this data should be cached for five seconds"} 22 | 23 | 24 | @app.get("/cache_json_encoder") 25 | @cache() 26 | def cache_json_encoder(): 27 | return { 28 | "success": True, 29 | "start_time": datetime(2021, 4, 20, 7, 17, 17), 30 | "finish_by": date(2021, 4, 21), 31 | "final_calc": Decimal(3.14), 32 | } 33 | 34 | 35 | @app.get("/cache_one_hour") 36 | @cache_one_hour() 37 | def partial_cache_one_hour(response: Response): 38 | 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 48 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import time 4 | from datetime import datetime 5 | from decimal import Decimal 6 | 7 | import pytest 8 | from fastapi.testclient import TestClient 9 | from fastapi_redis_cache.client import HTTP_TIME 10 | 11 | from fastapi_redis_cache.util import deserialize_json 12 | from tests.main import app 13 | 14 | client = TestClient(app) 15 | MAX_AGE_REGEX = re.compile(r"max-age=(?P\d+)") 16 | 17 | 18 | def test_cache_never_expire(): 19 | # Initial request, X-FastAPI-Cache header field should equal "Miss" 20 | response = client.get("/cache_never_expire") 21 | assert response.status_code == 200 22 | assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} 23 | assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss" 24 | assert "cache-control" in response.headers 25 | assert "expires" in response.headers 26 | assert "etag" in response.headers 27 | 28 | # Send request to same endpoint, X-FastAPI-Cache header field should now equal "Hit" 29 | response = client.get("/cache_never_expire") 30 | assert response.status_code == 200 31 | assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} 32 | assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Hit" 33 | assert "cache-control" in response.headers 34 | assert "expires" in response.headers 35 | assert "etag" in response.headers 36 | 37 | 38 | def test_cache_expires(): 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" 43 | response = client.get("/cache_expires") 44 | assert response.status_code == 200 45 | assert response.json() == {"success": True, "message": "this data should be cached for five seconds"} 46 | assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss" 47 | assert "cache-control" in response.headers 48 | assert "expires" in response.headers 49 | assert "etag" in response.headers 50 | 51 | # Store eTag value from response header 52 | check_etag = response.headers["etag"] 53 | 54 | # Send request, X-FastAPI-Cache header field should now equal "Hit" 55 | response = client.get("/cache_expires") 56 | assert response.status_code == 200 57 | assert response.json() == {"success": True, "message": "this data should be cached for five seconds"} 58 | assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Hit" 59 | 60 | # Verify eTag value matches the value stored from the initial response 61 | assert "etag" in response.headers 62 | assert response.headers["etag"] == check_etag 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 90 | response = client.get("/cache_expires") 91 | assert response.status_code == 200 92 | assert response.json() == {"success": True, "message": "this data should be cached for five seconds"} 93 | assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss" 94 | assert "cache-control" in response.headers 95 | assert "expires" in response.headers 96 | assert "etag" in response.headers 97 | 98 | # Check eTag value again. Since data is the same, the value should still match 99 | assert response.headers["etag"] == check_etag 100 | 101 | 102 | 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. 105 | response = client.get("/cache_json_encoder") 106 | assert response.status_code == 200 107 | response_json = response.json() 108 | assert response_json == { 109 | "success": True, 110 | "start_time": {"_spec_type": "", "val": "04/20/2021 07:17:17 AM "}, 111 | "finish_by": {"_spec_type": "", "val": "04/21/2021"}, 112 | "final_calc": { 113 | "_spec_type": "", 114 | "val": "3.140000000000000124344978758017532527446746826171875", 115 | }, 116 | } 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. 120 | json_dict = deserialize_json(json.dumps(response_json)) 121 | assert json_dict["start_time"] == datetime(2021, 4, 20, 7, 17, 17) 122 | assert json_dict["finish_by"] == datetime(2021, 4, 21) 123 | assert json_dict["final_calc"] == Decimal(3.14) 124 | 125 | 126 | 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 129 | response = client.get("/cache_never_expire", headers={"cache-control": "no-cache"}) 130 | assert response.status_code == 200 131 | assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} 132 | assert "x-fastapi-cache" not in response.headers 133 | assert "cache-control" not in response.headers 134 | assert "expires" not in response.headers 135 | assert "etag" not in response.headers 136 | 137 | 138 | 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 141 | response = client.get("/cache_never_expire", headers={"cache-control": "no-store"}) 142 | assert response.status_code == 200 143 | assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} 144 | assert "x-fastapi-cache" not in response.headers 145 | assert "cache-control" not in response.headers 146 | assert "expires" not in response.headers 147 | assert "etag" not in response.headers 148 | 149 | 150 | def test_if_none_match(): 151 | # Initial request, response data is added to cache 152 | response = client.get("/cache_never_expire") 153 | assert response.status_code == 200 154 | assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} 155 | assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss" 156 | assert "cache-control" in response.headers 157 | assert "expires" in response.headers 158 | assert "etag" in response.headers 159 | 160 | # Store correct eTag value from response header 161 | etag = response.headers["etag"] 162 | # Create another eTag value that is different from the correct value 163 | invalid_etag = "W/-5480454928453453778" 164 | 165 | # Send request to same endpoint where If-None-Match header contains both valid and invalid eTag values 166 | response = client.get("/cache_never_expire", headers={"if-none-match": f"{etag}, {invalid_etag}"}) 167 | 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 175 | response = client.get("/cache_never_expire", headers={"if-none-match": "*"}) 176 | 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 184 | response = client.get("/cache_never_expire", headers={"if-none-match": invalid_etag}) 185 | assert response.status_code == 200 186 | assert response.json() == {"success": True, "message": "this data can be cached indefinitely"} 187 | assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Hit" 188 | assert "cache-control" in response.headers 189 | assert "expires" in response.headers 190 | assert "etag" in response.headers 191 | 192 | 193 | 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. 196 | response = client.get("/cache_one_hour") 197 | assert response.status_code == 200 198 | assert response.json() == {"success": True, "message": "this data should be cached for one hour"} 199 | assert "x-fastapi-cache" in response.headers and response.headers["x-fastapi-cache"] == "Miss" 200 | assert "cache-control" in response.headers 201 | match = MAX_AGE_REGEX.search(response.headers.get("cache-control")) 202 | assert match and int(match.groupdict()["ttl"]) == 3600 203 | assert "expires" in response.headers 204 | 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 217 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.7.0 3 | envlist = py37, py38, py39 4 | isolated_build=True 5 | 6 | [gh-actions] 7 | python = 8 | 3.7: py37 9 | 3.8: py38 10 | 3.9: py39 11 | 12 | [testenv] 13 | deps = 14 | black 15 | coverage 16 | fakeredis 17 | flake8 18 | isort 19 | pytest 20 | pytest-cov 21 | pytest-flake8 22 | pytest-random-order 23 | requests 24 | 25 | commands = 26 | isort . 27 | black . 28 | pytest 29 | 30 | [paths] 31 | source = 32 | src/fastapi_redis_cache 33 | **/site-packages/fastapi_redis_cache 34 | 35 | [coverage:report] 36 | skip_covered = True 37 | skip_empty = True 38 | # Regexes for lines to exclude from consideration 39 | exclude_lines = 40 | # Have to re-enable the standard pragma 41 | pragma: no cover 42 | 43 | # Don't complain about missing debug-only code: 44 | def __repr__ 45 | if self\.debug 46 | 47 | # Don't complain if tests don't hit defensive assertion code: 48 | raise AssertionError 49 | raise NotImplementedError 50 | 51 | # Don't complain if non-runnable code isn't run: 52 | if 0: 53 | if __name__ == .__main__.: 54 | 55 | [coverage:html] 56 | directory = coverage_html 57 | --------------------------------------------------------------------------------