Repository: florimondmanca/asgi-caches

Issue:
Disallow multiple endpoint-level cache configuration
Currently, it is theoretically possible for a user to do the following:

```python
@cached(cache)
@cached(cache)
...
@cached(cache)
class Endpoint(HTTPEndpoint):
    ...
```

i.e. to apply the `@cached` decorator introduced by #15 an arbitrary number of times.

I can't see a situation when this would make sense. Even when #3 lands, the only reasonable behavior is to apply the top-most decorator, which means others decorators have no effect and should then be disallowed to prevent confusion.

Users could also have `CacheMiddleware` applied onto the app, and then applied `cached` to an endpoint. This shouldn't be allowed either.

To implement this feature:

- Modify the `@cached` decorator to raise an exception (`ValueError` is probably fine) if the decorated `app` is an instance of `CacheMiddleware` (because this means that the decorator has already been applied).
- Add a test to verify that the exception is raised if we try to apply the decorator twice.

Comments:

Patch:
diff --git a/src/asgi_caches/exceptions.py b/src/asgi_caches/exceptions.py
index 29a3bbf..1d1d270 100644
--- a/src/asgi_caches/exceptions.py
+++ b/src/asgi_caches/exceptions.py
@@ -20,3 +20,10 @@ class ResponseNotCachable(ASGICachesException):
     def __init__(self, response: Response) -> None:
         super().__init__()
         self.response = response
+
+
+class DuplicateCaching(ASGICachesException):
+    """
+    Raised when more than one cache middleware
+    were detected in the middleware stack.
+    """
diff --git a/src/asgi_caches/middleware.py b/src/asgi_caches/middleware.py
index 13673ed..206d5aa 100644
--- a/src/asgi_caches/middleware.py
+++ b/src/asgi_caches/middleware.py
@@ -6,7 +6,7 @@ from starlette.requests import Request
 from starlette.responses import Response
 from starlette.types import ASGIApp, Message, Receive, Scope, Send
 
-from .exceptions import RequestNotCachable, ResponseNotCachable
+from .exceptions import DuplicateCaching, RequestNotCachable, ResponseNotCachable
 from .utils.cache import get_from_cache, patch_cache_control, store_in_cache
 from .utils.logging import HIT_EXTRA, MISS_EXTRA, get_logger
 from .utils.misc import kvformat
@@ -32,6 +32,18 @@ class CacheMiddleware:
             await self.app(scope, receive, send)
             return
 
+        if "__asgi_caches__" in scope:
+            raise DuplicateCaching(
+                "Another `CacheMiddleware` was detected in the middleware stack.\n"
+                "HINT: this exception probably occurred because:\n"
+                "- You wrapped an application around `CacheMiddleware` multiple "
+                "times.\n"
+                "- You tried to apply `@cached()` onto an endpoint, but "
+                "the application is already wrapped around a `CacheMiddleware`."
+            )
+
+        scope["__asgi_caches__"] = True
+
         responder = CacheResponder(self.app, cache=self.cache)
         await responder(scope, receive, send)
 


Test patch:
diff --git a/tests/test_functional.py b/tests/test_functional.py
index f0ca319..7278d30 100644
--- a/tests/test_functional.py
+++ b/tests/test_functional.py
@@ -10,6 +10,7 @@ from starlette.requests import Request
 from starlette.responses import JSONResponse, PlainTextResponse, Response
 
 from asgi_caches.decorators import cache_control, cached
+from asgi_caches.exceptions import DuplicateCaching
 from asgi_caches.middleware import CacheMiddleware
 
 cache = Cache("locmem://default", ttl=2 * 60)
@@ -100,3 +101,20 @@ async def test_caching(client: httpx.AsyncClient) -> None:
 
     r = await client.get("/exp")
     assert e_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_duplicate_caching() -> None:
+    app = Starlette()
+    app.add_middleware(CacheMiddleware, cache=cache)
+
+    @app.route("/duplicate_cache")
+    @cached(special_cache)
+    class DuplicateCache(HTTPEndpoint):
+        pass
+
+    client = httpx.AsyncClient(app=app, base_url="http://testserver")
+
+    async with cache, special_cache, client:
+        with pytest.raises(DuplicateCaching):
+            await client.get("/duplicate_cache")