Repository: sanic-org/sanic-ext
Pull request #129 (2022-09-19 19:20:13)

Add request counter
Coming off the discussion [re: the worker manager](https://www.youtube.com/watch?v=m8HCO8NK7HE&t=1976s), I implemented the idea of adding a request counter. I thought of a few different strategies. Most important to me was that it not be forced upon users who do not opt in.

The obvious first choice was of course `config`. But, I do not want to even include a check for this on *every* request. I thought about using a touchup scheme, but we do not have one that is currently setup for general conditional checks. Maybe we should, but that is outside the scope here.

Doing it on `Request.__init__` is the easiest. So, I thought perhaps as a subclass we can get the result without any of the consequences. It does however bring the question about whether a self-contained opinionated piece of logic belongs in the main project at all, or if it should be pushed over to Sanic Extensions.

Thoughts?

---

Implementation:

```python
from sanic.request import CountedRequest

app = Sanic(..., request_class=CountedRequest)

@app.get("/")
async def handler(request: CountedRequest):
    return json({"count": request.count})
```

FYI - the result will show in the worker state

![image](https://user-images.githubusercontent.com/166269/190922460-43bd2cfc-f81a-443b-b84f-07b6ce475cbf.png)


ahopkins: > Looks good to me.
> 
> But test automation failed?

Yeah, have not checked. But I am 99% sure it is because we need to use the unreleased `sanic-testing` because of the changes to the workers in core.

Patch:
diff --git a/sanic_ext/__init__.py b/sanic_ext/__init__.py
index 71eee0e..b9ba15d 100644
--- a/sanic_ext/__init__.py
+++ b/sanic_ext/__init__.py
@@ -6,6 +6,7 @@ from sanic_ext.extensions.base import Extension
 from sanic_ext.extensions.http.cors import cors
 from sanic_ext.extensions.openapi import openapi
 from sanic_ext.extensions.templating.render import render
+from sanic_ext.extras.request import CountedRequest
 from sanic_ext.extras.serializer.decorator import serializer
 from sanic_ext.extras.validation.decorator import validate
 
@@ -13,6 +14,7 @@ __version__ = version("sanic-ext")
 
 __all__ = [
     "Config",
+    "CountedRequest",
     "Extend",
     "Extension",
     "cors",
diff --git a/sanic_ext/extras/request.py b/sanic_ext/extras/request.py
new file mode 100644
index 0000000..f60daf2
--- /dev/null
+++ b/sanic_ext/extras/request.py
@@ -0,0 +1,46 @@
+from itertools import count
+
+from sanic import Request, Sanic
+from sanic.compat import Header
+from sanic.models.protocol_types import TransportProtocol
+
+
+class CountedRequest(Request):
+    __slots__ = ()
+
+    _counter = count()
+    count = next(_counter)
+
+    def __init__(
+        self,
+        url_bytes: bytes,
+        headers: Header,
+        version: str,
+        method: str,
+        transport: TransportProtocol,
+        app: Sanic,
+        head: bytes = b"",
+        stream_id: int = 0,
+    ):
+        super().__init__(
+            url_bytes,
+            headers,
+            version,
+            method,
+            transport,
+            app,
+            head,
+            stream_id,
+        )
+        self.__class__._increment()
+        if hasattr(self.app, "multiplexer"):
+            self.app.multiplexer.state["request_count"] = self.__class__.count
+
+    @classmethod
+    def _increment(cls):
+        cls.count = next(cls._counter)
+
+    @classmethod
+    def reset_count(cls):
+        cls._counter = count()
+        cls.count = next(cls._counter)
diff --git a/setup.cfg b/setup.cfg
index 4f5ca27..3b2eb3e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -28,7 +28,7 @@ sanic_ext =
 
 [options.extras_require]
 test =
-    sanic_testing==22.9.0b1
+    sanic_testing>=22.9.0b1
     coverage
     pytest
     pytest-cov


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index f4a1f0e..1f022c6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -22,6 +22,7 @@ def reset_globals():
     OperationStore.reset()
     Extend.reset()
     Extension.reset()
+    Sanic._app_registry.clear()
 
 
 @pytest.fixture(autouse=True)
diff --git a/tests/extra/test_request_counted.py b/tests/extra/test_request_counted.py
new file mode 100644
index 0000000..5f26e04
--- /dev/null
+++ b/tests/extra/test_request_counted.py
@@ -0,0 +1,42 @@
+from unittest.mock import Mock
+
+import pytest
+from sanic import Sanic
+from sanic.compat import Header
+from sanic.response import json
+from sanic_testing.reusable import ReusableClient
+
+from sanic_ext import CountedRequest
+
+
+@pytest.fixture(autouse=True)
+def reset_counter():
+    yield
+    CountedRequest.reset_count()
+
+
+def test_counter_increments(app: Sanic):
+    app.request_class = CountedRequest
+
+    @app.get("/")
+    async def handler(request: CountedRequest):
+        return json({"count": request.count})
+
+    @app.get("/info")
+    async def info(request: CountedRequest):
+        return json({"state": request.app.m.state})
+
+    with ReusableClient(app) as client:
+        for i in range(1, 10):
+            _, response = client.get("/")
+            assert response.json["count"] == i
+
+
+def test_counter_increment_on_state(app: Sanic):
+    mock = Mock()
+    mock.state = {}
+    app.multiplexer = mock
+
+    for i in range(1, 10):
+        CountedRequest(b"/", Header({}), "", "", Mock(), app)
+        assert CountedRequest.count == i