From: Jeremy Lainé Date: Sun, 1 Sep 2019 22:36:19 +0000 (+0200) Subject: Add support for server push X-Git-Tag: 0.12.9~1^2 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=refs%2Fpull%2F629%2Fhead;p=thirdparty%2Fstarlette.git Add support for server push This adds support for HTTP/2 and HTTP/3 server push by adding a Request.send_push_promise method, which signals to push-enabled servers that a push should be sent. --- diff --git a/docs/server-push.md b/docs/server-push.md new file mode 100644 index 00000000..ba4d31ac --- /dev/null +++ b/docs/server-push.md @@ -0,0 +1,34 @@ + +Starlette includes support for HTTP/2 and HTTP/3 server push, making it +possible to push resources to the client to speed up page load times. + +### `Request.send_push_promise` + +Used to initiate a server push for a resource. If server push is not available +this method does nothing. + +Signature: `send_push_promise(path)` + +* `path` - A string denoting the path of the resource. + +```python +from starlette.applications import Starlette +from starlette.responses import HTMLResponse +from starlette.staticfiles import StaticFiles + +app = Starlette() + + +@app.route("/") +async def homepage(request): + """ + Homepage which uses server push to deliver the stylesheet. + """ + await request.send_push_promise("/static/style.css") + return HTMLResponse( + '' + ) + + +app.mount("/static", StaticFiles(directory="static")) +``` diff --git a/mkdocs.yml b/mkdocs.yml index 2ec6a97c..d35af5d1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,6 +25,7 @@ nav: - API Schemas: 'schemas.md' - Events: 'events.md' - Background Tasks: 'background.md' + - Server Push: 'server-push.md' - Exceptions: 'exceptions.md' - Configuration: 'config.md' - Test Client: 'testclient.md' diff --git a/starlette/requests.py b/starlette/requests.py index a71c9075..a831a893 100644 --- a/starlette/requests.py +++ b/starlette/requests.py @@ -6,7 +6,7 @@ from collections.abc import Mapping from starlette.datastructures import URL, Address, FormData, Headers, QueryParams, State from starlette.formparsers import FormParser, MultiPartParser -from starlette.types import Message, Receive, Scope +from starlette.types import Message, Receive, Scope, Send try: from multipart.multipart import parse_options_header @@ -14,6 +14,15 @@ except ImportError: # pragma: nocover parse_options_header = None # type: ignore +SERVER_PUSH_HEADERS_TO_COPY = { + "accept", + "accept-encoding", + "accept-language", + "cache-control", + "user-agent", +} + + class ClientDisconnect(Exception): pass @@ -121,11 +130,18 @@ async def empty_receive() -> Message: raise RuntimeError("Receive channel has not been made available") +async def empty_send(message: Message) -> None: + raise RuntimeError("Send channel has not been made available") + + class Request(HTTPConnection): - def __init__(self, scope: Scope, receive: Receive = empty_receive): + def __init__( + self, scope: Scope, receive: Receive = empty_receive, send: Send = empty_send + ): super().__init__(scope) assert scope["type"] == "http" self._receive = receive + self._send = send self._stream_consumed = False self._is_disconnected = False @@ -206,3 +222,15 @@ class Request(HTTPConnection): self._is_disconnected = True return self._is_disconnected + + async def send_push_promise(self, path: str) -> None: + if "http.response.push" in self.scope.get("extensions", {}): + raw_headers = [] + for name in SERVER_PUSH_HEADERS_TO_COPY: + for value in self.headers.getlist(name): + raw_headers.append( + (name.encode("latin-1"), value.encode("latin-1")) + ) + await self._send( + {"type": "http.response.push", "path": path, "headers": raw_headers} + ) diff --git a/starlette/routing.py b/starlette/routing.py index 2c1f815d..4f955374 100644 --- a/starlette/routing.py +++ b/starlette/routing.py @@ -36,7 +36,7 @@ def request_response(func: typing.Callable) -> ASGIApp: is_coroutine = asyncio.iscoroutinefunction(func) async def app(scope: Scope, receive: Receive, send: Send) -> None: - request = Request(scope, receive=receive) + request = Request(scope, receive=receive, send=send) if is_coroutine: response = await func(request) else: diff --git a/tests/test_requests.py b/tests/test_requests.py index 03c74c45..defdf3a4 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -300,3 +300,61 @@ def test_chunked_encoding(): response = client.post("/", data=post_body()) assert response.json() == {"body": "foobar"} + + +def test_request_send_push_promise(): + async def app(scope, receive, send): + # the server is push-enabled + scope["extensions"]["http.response.push"] = {} + + request = Request(scope, receive, send) + await request.send_push_promise("/style.css") + + response = JSONResponse({"json": "OK"}) + await response(scope, receive, send) + + client = TestClient(app) + response = client.get("/") + assert response.json() == {"json": "OK"} + + +def test_request_send_push_promise_without_push_extension(): + """ + If server does not support the `http.response.push` extension, + .send_push_promise() does nothing. + """ + + async def app(scope, receive, send): + request = Request(scope) + await request.send_push_promise("/style.css") + + response = JSONResponse({"json": "OK"}) + await response(scope, receive, send) + + client = TestClient(app) + response = client.get("/") + assert response.json() == {"json": "OK"} + + +def test_request_send_push_promise_without_setting_send(): + """ + If Request is instantiated without the send channel, then + .send_push_promise() is not available. + """ + + async def app(scope, receive, send): + # the server is push-enabled + scope["extensions"]["http.response.push"] = {} + + data = "OK" + request = Request(scope) + try: + await request.send_push_promise("/style.css") + except RuntimeError: + data = "Send channel not available" + response = JSONResponse({"json": data}) + await response(scope, receive, send) + + client = TestClient(app) + response = client.get("/") + assert response.json() == {"json": "Send channel not available"}