From bc224549f5d982a825725ac9f0315a0851ee278b Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Wed, 2 Apr 2025 23:54:01 -0300 Subject: [PATCH 1/5] refactor(routes)!: replace fastapi-cache2 with aioache --- main.py | 19 ++++++++++--------- requirements.txt | 2 +- routes/player_route.py | 26 +++++++++++++++----------- tests/test_main.py | 29 ++++++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/main.py b/main.py index c90eb45..881c878 100644 --- a/main.py +++ b/main.py @@ -3,23 +3,24 @@ # ------------------------------------------------------------------------------ from contextlib import asynccontextmanager +import logging +from typing import AsyncIterator from fastapi import FastAPI -from fastapi_cache import FastAPICache -from fastapi_cache.backends.inmemory import InMemoryBackend from routes import player_route +# https://github.com/encode/uvicorn/issues/562 +UVICORN_LOGGER = "uvicorn.error" +logger = logging.getLogger(UVICORN_LOGGER) @asynccontextmanager -async def lifespan_context_manager(_): +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + """" + Lifespan event handler for FastAPI. """ - Context manager for the FastAPI app lifespan. - - Initializes FastAPICache with an InMemoryBackend for the duration of the app's lifespan. - """ - FastAPICache.init(InMemoryBackend()) + logger.info("Lifespan event handler execution complete.") yield -app = FastAPI(lifespan=lifespan_context_manager, +app = FastAPI(lifespan=lifespan, title="python-samples-fastapi-restful", description="🧪 Proof of Concept for a RESTful API made with Python 3 and FastAPI", version="1.0.0",) diff --git a/requirements.txt b/requirements.txt index 223c4d4..5134d6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # https://fastapi.tiangolo.com/#standard-dependencies fastapi[standard]==0.115.12 -fastapi-cache2==0.2.2 SQLAlchemy==2.0.40 aiosqlite==0.21.0 +aiocache==0.12.3 diff --git a/routes/player_route.py b/routes/player_route.py index ea7be4b..3afd562 100644 --- a/routes/player_route.py +++ b/routes/player_route.py @@ -3,18 +3,19 @@ # ------------------------------------------------------------------------------ from typing import List -from fastapi import APIRouter, Body, Depends, HTTPException, status, Path +from fastapi import APIRouter, Body, Depends, HTTPException, status, Path, Response from sqlalchemy.ext.asyncio import AsyncSession -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache +from aiocache import SimpleMemoryCache from data.player_database import generate_async_session from models.player_model import PlayerModel from services import player_service api_router = APIRouter() +simple_memory_cache = SimpleMemoryCache() -CACHING_TIME_IN_SECONDS = 600 +CACHE_KEY = "players" +CACHE_TTL = 600 # 10 minutes # POST ------------------------------------------------------------------------- @@ -43,7 +44,7 @@ async def post_async( if player: raise HTTPException(status_code=status.HTTP_409_CONFLICT) await player_service.create_async(async_session, player_model) - await FastAPICache.clear() + await simple_memory_cache.clear(CACHE_KEY) # GET -------------------------------------------------------------------------- @@ -55,8 +56,8 @@ async def post_async( summary="Retrieves a collection of Players", tags=["Players"] ) -@cache(expire=CACHING_TIME_IN_SECONDS) async def get_all_async( + response: Response, async_session: AsyncSession = Depends(generate_async_session) ): """ @@ -68,7 +69,12 @@ async def get_all_async( Returns: List[PlayerModel]: A list of Pydantic models representing all players. """ - players = await player_service.retrieve_all_async(async_session) + players = await simple_memory_cache.get(CACHE_KEY) + response.headers["X-Cache"] = "HIT" + if not players: + players = await player_service.retrieve_all_async(async_session) + await simple_memory_cache.set(CACHE_KEY, players, ttl=CACHE_TTL) + response.headers["X-Cache"] = "MISS" return players @@ -79,7 +85,6 @@ async def get_all_async( summary="Retrieves a Player by its Id", tags=["Players"] ) -@cache(expire=CACHING_TIME_IN_SECONDS) async def get_by_id_async( player_id: int = Path(..., title="The ID of the Player"), async_session: AsyncSession = Depends(generate_async_session) @@ -110,7 +115,6 @@ async def get_by_id_async( summary="Retrieves a Player by its Squad Number", tags=["Players"] ) -@cache(expire=CACHING_TIME_IN_SECONDS) async def get_by_squad_number_async( squad_number: int = Path(..., title="The Squad Number of the Player"), async_session: AsyncSession = Depends(generate_async_session) @@ -162,7 +166,7 @@ async def put_async( if not player: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) await player_service.update_async(async_session, player_model) - await FastAPICache.clear() + await simple_memory_cache.clear(CACHE_KEY) # DELETE ----------------------------------------------------------------------- @@ -191,4 +195,4 @@ async def delete_async( if not player: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) await player_service.delete_async(async_session, player_id) - await FastAPICache.clear() + await simple_memory_cache.clear(CACHE_KEY) diff --git a/tests/test_main.py b/tests/test_main.py index cb46689..f705ee7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -10,6 +10,33 @@ # GET /players/ ---------------------------------------------------------------- +def test_given_get_when_request_is_initial_then_response_header_x_cache_should_be_miss(client): + """ + Given GET /players/ + when request is initial + then response Header X-Cache value should be MISS + """ + # Act + response = client.get(PATH) + + # Assert + assert "X-Cache" in response.headers + assert response.headers.get("X-Cache") == "MISS" + +def test_given_get_when_request_is_subsequent_then_response_header_x_cache_should_be_hit(client): + """ + Given GET /players/ + when request is subsequent + then response Header X-Cache should be HIT + """ + # Act + client.get(PATH) # initial + response = client.get(PATH) # subsequent (cached) + + # Assert + assert "X-Cache" in response.headers + assert response.headers.get("X-Cache") == "HIT" + def test_given_get_when_request_path_has_no_id_then_response_status_code_should_be_200_ok(client): """ Given GET /players/ @@ -26,7 +53,7 @@ def test_given_get_when_request_path_has_no_id_then_response_body_should_be_coll """ Given GET /players/ when request path has no ID - then response Status Code should be collection of players + then response Body should be collection of players """ # Act response = client.get(PATH) From 76434ec84ba718c9ffc6193f40961439f5340532 Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Wed, 2 Apr 2025 23:54:32 -0300 Subject: [PATCH 2/5] chore: update debug launch config to open Swagger UI --- .vscode/launch.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 2222a53..01a12a7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,12 @@ "request": "launch", "module": "uvicorn", "args": ["main:app", "--reload", "--port", "9000"], - "jinja": true + "jinja": true, + "serverReadyAction": { + "action": "openExternally", + "pattern": "Uvicorn running on .*:(\\d+)", + "uriFormat": "http://localhost:%s/docs" + } } ] } From d7400d85f244dd664c58da6db1c3c6c15e11e95c Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Wed, 2 Apr 2025 23:54:41 -0300 Subject: [PATCH 3/5] fix: prevent HTTP 307 Temporary Redirect --- .../python-samples-fastapi-restful.postman_collection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/postman_collections/python-samples-fastapi-restful.postman_collection.json b/postman_collections/python-samples-fastapi-restful.postman_collection.json index 96266c1..40e6ca2 100644 --- a/postman_collections/python-samples-fastapi-restful.postman_collection.json +++ b/postman_collections/python-samples-fastapi-restful.postman_collection.json @@ -22,7 +22,7 @@ } }, "url": { - "raw": "http://localhost:9000/players", + "raw": "http://localhost:9000/players/", "protocol": "http", "host": ["localhost"], "port": "9000", @@ -50,7 +50,7 @@ } }, "url": { - "raw": "http://localhost:9000/players", + "raw": "http://localhost:9000/players/", "protocol": "http", "host": ["localhost"], "port": "9000", From 3239cae616c6e7adf65665427a48e9d31ba3d54e Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Wed, 2 Apr 2025 23:54:51 -0300 Subject: [PATCH 4/5] chore: update install section --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f0aada7..331db21 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ The following is a simplified dependency diagram of modules and main libraries: ## Install ```console -pip install --requirement requirements.txt +pip install -r requirements.txt +pip install -r requirements-lint.txt +pip install -r requirements-test.txt ``` ## Start From 613bd6639ae381778835506322a0e69985f2ab75 Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Thu, 3 Apr 2025 00:07:04 -0300 Subject: [PATCH 5/5] fix(tests): ensure proper isolation changing pytest fixture scope --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5574c61..652a53f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ warnings.filterwarnings("ignore", category=DeprecationWarning) -@pytest.fixture(scope="module") +@pytest.fixture(scope="function") def client(): with TestClient(app) as test_client: yield test_client