From c54a8b10bb848833defe366400079db1f09fa037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=2E=20K=C3=A4rkk=C3=A4inen?= <98187+Tronic@users.noreply.github.com> Date: Fri, 27 Sep 2019 00:11:31 +0300 Subject: [PATCH] Implement dict-like API on request objects for custom data. (#1666) * Implement dict-like API on request objects for custom data. * Updated docs about custom_context. --- docs/sanic/middleware.md | 33 ++++++++++++++++------ sanic/request.py | 33 ++++++++++++++++++---- tests/test_request_data.py | 58 +++++++++++++++++++++++++++++++++++--- tests/test_requests.py | 3 -- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/docs/sanic/middleware.md b/docs/sanic/middleware.md index 820e3d85..f4c634e2 100644 --- a/docs/sanic/middleware.md +++ b/docs/sanic/middleware.md @@ -39,8 +39,8 @@ app = Sanic(__name__) @app.middleware('request') async def add_key(request): - # Add a key to request object like dict object - request['foo'] = 'bar' + # Arbitrary data may be stored in request context: + request.ctx.foo = 'bar' @app.middleware('response') @@ -53,16 +53,21 @@ async def prevent_xss(request, response): response.headers["x-xss-protection"] = "1; mode=block" +@app.get("/") +async def index(request): + return sanic.response.text(request.ctx.foo) + + app.run(host="0.0.0.0", port=8000) ``` -The above code will apply the three middleware in order. The first middleware -**add_key** will add a new key `foo` into `request` object. This worked because -`request` object can be manipulated like `dict` object. Then, the second middleware -**custom_banner** will change the HTTP response header *Server* to -*Fake-Server*, and the last middleware **prevent_xss** will add the HTTP -header for preventing Cross-Site-Scripting (XSS) attacks. These two functions -are invoked *after* a user function returns a response. +The three middlewares are executed in order: + +1. The first request middleware **add_key** adds a new key `foo` into request context. +2. Request is routed to handler **index**, which gets the key from context and returns a text response. +3. The first response middleware **custom_banner** changes the HTTP response header *Server* to +say *Fake-Server* +4. The second response middleware **prevent_xss** adds the HTTP header for preventing Cross-Site-Scripting (XSS) attacks. ## Responding early @@ -81,6 +86,16 @@ async def halt_response(request, response): return text('I halted the response') ``` +## Custom context + +Arbitrary data may be stored in `request.ctx`. A typical use case +would be to store the user object acquired from database in an authentication +middleware. Keys added are accessible to all later middleware as well as +the handler over the duration of the request. + +Custom context is reserved for applications and extensions. Sanic itself makes +no use of it. + ## Listeners If you want to execute startup/teardown code as your server starts or closes, you can use the following listeners: diff --git a/sanic/request.py b/sanic/request.py index 690213a3..972f7dbf 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -4,6 +4,7 @@ import warnings from collections import defaultdict, namedtuple from http.cookies import SimpleCookie +from types import SimpleNamespace from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse from httptools import parse_url # type: ignore @@ -62,7 +63,7 @@ class StreamBuffer: return self._queue.full() -class Request(dict): +class Request: """Properties of an HTTP request such as URL, headers, etc.""" __slots__ = ( @@ -75,6 +76,7 @@ class Request(dict): "_socket", "app", "body", + "ctx", "endpoint", "headers", "method", @@ -104,6 +106,7 @@ class Request(dict): # Init but do not inhale self.body_init() + self.ctx = SimpleNamespace() self.parsed_forwarded = None self.parsed_json = None self.parsed_form = None @@ -120,10 +123,30 @@ class Request(dict): self.__class__.__name__, self.method, self.path ) - def __bool__(self): - if self.transport: - return True - return False + def get(self, key, default=None): + """.. deprecated:: 19.9 + Custom context is now stored in `request.custom_context.yourkey`""" + return self.ctx.__dict__.get(key, default) + + def __contains__(self, key): + """.. deprecated:: 19.9 + Custom context is now stored in `request.custom_context.yourkey`""" + return key in self.ctx.__dict__ + + def __getitem__(self, key): + """.. deprecated:: 19.9 + Custom context is now stored in `request.custom_context.yourkey`""" + return self.ctx.__dict__[key] + + def __delitem__(self, key): + """.. deprecated:: 19.9 + Custom context is now stored in `request.custom_context.yourkey`""" + del self.ctx.__dict__[key] + + def __setitem__(self, key, value): + """.. deprecated:: 19.9 + Custom context is now stored in `request.custom_context.yourkey`""" + setattr(self.ctx, key, value) def body_init(self): self.body = [] diff --git a/tests/test_request_data.py b/tests/test_request_data.py index 061653bd..465ff53a 100644 --- a/tests/test_request_data.py +++ b/tests/test_request_data.py @@ -8,22 +8,72 @@ try: except ImportError: from json import loads - -def test_storage(app): +def test_custom_context(app): @app.middleware("request") def store(request): + request.ctx.user = "sanic" + request.ctx.session = None + + @app.route("/") + def handler(request): + # Accessing non-existant key should fail with AttributeError + try: + invalid = request.ctx.missing + except AttributeError as e: + invalid = str(e) + return json({ + "user": request.ctx.user, + "session": request.ctx.session, + "has_user": hasattr(request.ctx, "user"), + "has_session": hasattr(request.ctx, "session"), + "has_missing": hasattr(request.ctx, "missing"), + "invalid": invalid + }) + + request, response = app.test_client.get("/") + assert response.json == { + "user": "sanic", + "session": None, + "has_user": True, + "has_session": True, + "has_missing": False, + "invalid": "'types.SimpleNamespace' object has no attribute 'missing'", + } + + +# Remove this once the deprecated API is abolished. +def test_custom_context_old(app): + @app.middleware("request") + def store(request): + try: + request["foo"] + except KeyError: + pass request["user"] = "sanic" - request["sidekick"] = "tails" + sidekick = request.get("sidekick", "tails") # Item missing -> default + request["sidekick"] = sidekick + request["bar"] = request["sidekick"] del request["sidekick"] @app.route("/") def handler(request): return json( - {"user": request.get("user"), "sidekick": request.get("sidekick")} + { + "user": request.get("user"), + "sidekick": request.get("sidekick"), + "has_bar": "bar" in request, + "has_sidekick": "sidekick" in request, + } ) request, response = app.test_client.get("/") + assert response.json == { + "user": "sanic", + "sidekick": None, + "has_bar": True, + "has_sidekick": False, + } response_json = loads(response.text) assert response_json["user"] == "sanic" assert response_json.get("sidekick") is None diff --git a/tests/test_requests.py b/tests/test_requests.py index 522d806e..b6f91feb 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1499,9 +1499,6 @@ def test_request_bool(app): request, response = app.test_client.get("/") assert bool(request) - request.transport = False - assert not bool(request) - def test_request_parsing_form_failed(app, caplog): @app.route("/", methods=["POST"])