Repository: encode/starlette

Issue:
Request should present a scope-like interface
The `Request` class should present a dict-like interface so that it can be used in the same way as `scope`. Should also allow it to be instantiated without a `receive` channel being set initially.


Discussion:

Resolving patch:
diff --git a/starlette/decorators.py b/starlette/decorators.py
--- a/starlette/decorators.py
+++ b/starlette/decorators.py
@@ -5,8 +5,10 @@
 
 def asgi_application(func):
     def app(scope: Scope) -> ASGIInstance:
+        request = Request(scope)
+
         async def awaitable(receive: Receive, send: Send) -> None:
-            request = Request(scope, receive)
+            request.set_receive_channel(receive)
             response = func(request)
             await response(receive, send)
 
diff --git a/starlette/request.py b/starlette/request.py
--- a/starlette/request.py
+++ b/starlette/request.py
@@ -1,19 +1,33 @@
 from starlette.datastructures import URL, Headers, QueryParams
+from collections.abc import Mapping
 import json
+import typing
 
 
-class Request:
-    def __init__(self, scope, receive):
+class Request(Mapping):
+    def __init__(self, scope, receive=None):
         self._scope = scope
         self._receive = receive
         self._stream_consumed = False
 
+    def __getitem__(self, key):
+        return self._scope[key]
+
+    def __iter__(self):
+        return iter(self._scope)
+
+    def __len__(self):
+        return len(self._scope)
+
+    def set_receive_channel(self, receive):
+        self._receive = receive
+
     @property
-    def method(self):
+    def method(self) -> str:
         return self._scope["method"]
 
     @property
-    def url(self):
+    def url(self) -> URL:
         if not hasattr(self, "_url"):
             scheme = self._scope["scheme"]
             host, port = self._scope["server"]
@@ -32,7 +46,7 @@ def url(self):
         return self._url
 
     @property
-    def headers(self):
+    def headers(self) -> Headers:
         if not hasattr(self, "_headers"):
             self._headers = Headers(
                 [
@@ -43,7 +57,7 @@ def headers(self):
         return self._headers
 
     @property
-    def query_params(self):
+    def query_params(self) -> QueryParams:
         if not hasattr(self, "_query_params"):
             query_string = self._scope["query_string"].decode()
             self._query_params = QueryParams(query_string)
@@ -57,6 +71,9 @@ async def stream(self):
         if self._stream_consumed:
             raise RuntimeError("Stream consumed")
 
+        if self._receive is None:
+            raise RuntimeError("Receive channel has not been made available")
+
         self._stream_consumed = True
         while True:
             message = await self._receive()


Test patch:
diff --git a/tests/test_request.py b/tests/test_request.py
--- a/tests/test_request.py
+++ b/tests/test_request.py
@@ -157,3 +157,37 @@ async def asgi(receive, send):
     client = TestClient(app)
     response = client.post("/", json={"a": "123"})
     assert response.json() == {"json": {"a": "123"}}
+
+
+def test_request_scope_interface():
+    """
+    A Request can be isntantiated with a scope, and presents a `Mapping`
+    interface.
+    """
+    request = Request({"method": "GET", "path": "/abc/"})
+    assert request["method"] == "GET"
+    assert dict(request) == {"method": "GET", "path": "/abc/"}
+    assert len(request) == 2
+
+
+def test_request_without_setting_receive():
+    """
+    If Request is instantiated without the receive channel, then .body()
+    is not available.
+    """
+
+    def app(scope):
+        async def asgi(receive, send):
+            request = Request(scope)
+            try:
+                data = await request.json()
+            except RuntimeError:
+                data = "Receive channel not available"
+            response = JSONResponse({"json": data})
+            await response(receive, send)
+
+        return asgi
+
+    client = TestClient(app)
+    response = client.post("/", json={"a": "123"})
+    assert response.json() == {"json": "Receive channel not available"}